mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-12 10:33:20 +00:00
0b8c787ac0
The .ifcview data is hugely redundant (repeated double instance matrices, patterned indices) — measured 12x zstd whole-file. Server Content-Encoding can't be used (it breaks HTTP Range), so compress PER-CHUNK into the format. Format (v16): geometry becomes per-chunk zstd(vertices)+zstd(indices) frames — each independently Range-fetchable, so streaming is intact — and the critical + deferred metadata blocks are single zstd frames. SidecarChunk carries the compressed blob offsets/sizes; applyStreamedChunk (render/upload) is UNCHANGED — decompression slots into the fetch. Full readSidecar (test/tooling) reconstructs by decompress+scatter. zstd: desktop links libzstd (also compresses at bake); the web build (Emscripten has no zstd port) FetchContent's the pinned zstd source and compiles its decompress-only subset for wasm — no vendored blob, same version as desktop. New SidecarCompress wraps it (compress guarded off under Emscripten). Both stream paths — desktop StreamingThread worker + sync fallback (readChunkGeometryCompressed) and web beginWebChunkLoad — decompress; readSidecarMetadataOnly / the web bootstrap / loadDeferredMetadataWeb decompress the metadata blocks. streamingByteProgress reports COMPRESSED bytes. MEASURED: a 752 MB v15 federation → 75 MB v16 (10x; per-file 6.7-15.3x); PP-PLP 118→15 MB, loads 13/13 chunks on web, 0 errors. Three fixes found while testing big federations on a real server: - Web-streamed race: streaming_from_web was set in the deferred-header callback (a round-trip after the model+chunks exist), so driveStreamingLoads could take the sync fopen path meanwhile → "failed to read/decompress chunk 0". Now set immediately after applyCachedModel. - OOM abort on 18 models: the pool grew unbounded until an alloc failed, but on web that's an uncatchable bad_alloc abort. Cap total pool capacity (setMaxTotalCapacity, 3 GB) so it stops before the heap ceiling, and raise MAXIMUM_MEMORY 2→4 GB (wasm32 max) for headroom. - Web never evicted (grow-or-block only). At the hard budget, fall through to the LRU/priority evictor so a big federation stays navigable (highest-contribution chunks win) instead of freezing with holes. 113/113 desktop + 6/6 web smoke pass. No back-compat: regenerate sidecars (desktop bakes v16; scratch conv tool migrates v15→v16). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
206 lines
11 KiB
C++
206 lines
11 KiB
C++
/********************************************************************************
|
|
* *
|
|
* This file is part of IfcOpenShell. *
|
|
* *
|
|
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
|
* it under the terms of the Lesser GNU General Public License as published by *
|
|
* the Free Software Foundation, either version 3.0 of the License, or *
|
|
* (at your option) any later version. *
|
|
* *
|
|
* IfcOpenShell is distributed in the hope that it will be useful, *
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
|
* Lesser GNU General Public License for more details. *
|
|
* *
|
|
* You should have received a copy of the Lesser GNU General Public License *
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
|
* *
|
|
********************************************************************************/
|
|
|
|
#ifndef WGPUBUFFERPOOL_H
|
|
#define WGPUBUFFERPOOL_H
|
|
|
|
#include <webgpu/webgpu.h>
|
|
|
|
#include <cstdint>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
// Multi-sub-buffer sub-allocator. Owns one or more fixed-size WGPUBuffers
|
|
// and hands out byte ranges within them.
|
|
//
|
|
// Why multiple sub-buffers: WebGPU caps any single buffer at
|
|
// `limits.maxBufferSize`, which on wgpu-native + Vulkan tops out
|
|
// around 2 GB regardless of how much GPU memory exists. The GL backend
|
|
// reaches 4+ GB by letting the driver sub-allocate across many
|
|
// VkDeviceMemory blocks behind one logical GL buffer; here we do the
|
|
// same explicitly — `per_sub_buffer_capacity` (set from a probe) is the
|
|
// largest single buffer that allocates cleanly, and the pool grows
|
|
// lazily by adding more sub-buffers of that size when alloc demand
|
|
// exceeds what existing sub-buffers can fit.
|
|
//
|
|
// Lifetime model: alloc/free are immediate. WebGPU guarantees that
|
|
// queue.writeBuffer to a just-freed range is correctly serialised against
|
|
// any prior submitted GPU reads — we never need to fence frees ourselves.
|
|
//
|
|
// Allocator: per-sub-buffer sorted free list with adjacent-range
|
|
// coalescing, first-fit across sub-buffers. Adequate for the chunk
|
|
// workload (a few hundred allocations of broadly similar size).
|
|
class BufferPool {
|
|
public:
|
|
// A handle to a previously-allocated range. Includes the underlying
|
|
// sub-buffer so callers (bind-group builders, queueWriteBuffer) can
|
|
// address the correct buffer; includes sub_idx so free() knows which
|
|
// sub-pool's bookkeeping to update.
|
|
struct Slice {
|
|
WGPUBuffer buffer = nullptr;
|
|
uint64_t offset = 0;
|
|
uint64_t size = 0;
|
|
int sub_idx = -1;
|
|
bool valid() const { return size > 0 && buffer != nullptr; }
|
|
};
|
|
|
|
BufferPool() = default;
|
|
~BufferPool();
|
|
|
|
BufferPool(const BufferPool&) = delete;
|
|
BufferPool& operator=(const BufferPool&) = delete;
|
|
|
|
// Record the device + usage + sub-buffer size. Does NOT allocate any
|
|
// sub-buffer here — that happens lazily on first alloc(). `instance`
|
|
// is needed so the pool can drain async PopErrorScope events when
|
|
// probing whether a new sub-buffer can be created.
|
|
void configure(WGPUInstance instance, WGPUDevice device,
|
|
WGPUBufferUsage usage,
|
|
uint64_t per_sub_buffer_capacity,
|
|
const char* label_prefix);
|
|
void destroy();
|
|
|
|
// Sub-allocate a range of `size` bytes, aligned to `align` (must be
|
|
// a power of two; typical: 256 for storage-buffer binding offsets).
|
|
// Tries every existing sub-buffer; if none can fit, attempts to add
|
|
// a new sub-buffer at per_sub_buffer_capacity. Returns an invalid
|
|
// Slice (size == 0) if no sub-buffer fits and growth fails.
|
|
Slice alloc(uint64_t size, uint64_t align);
|
|
// Return a slice to the free list. Coalesces with adjacent free
|
|
// ranges in the same sub-buffer.
|
|
void free(const Slice& s);
|
|
|
|
// Tally summed across every sub-buffer.
|
|
uint64_t total_capacity_bytes() const;
|
|
uint64_t total_used_bytes() const;
|
|
uint64_t total_free_bytes() const { return total_capacity_bytes() - total_used_bytes(); }
|
|
// Largest contiguous free run across all sub-buffers. Useful for
|
|
// evictor heuristics ("can this allocation even fit, ever, without
|
|
// eviction or growth?").
|
|
uint64_t largest_free_run_bytes() const;
|
|
// Per-sub-buffer count, for diagnostics / logging.
|
|
size_t sub_buffer_count() const { return sub_pools_.size(); }
|
|
uint64_t per_sub_buffer_capacity_bytes() const { return per_sub_buffer_capacity_; }
|
|
// Best estimate of the size a *future* sub-buffer would land at:
|
|
// last_growth_size_ if we've ever grown (or just been configured),
|
|
// else the configured per_sub_buffer_capacity. After the driver
|
|
// refuses a size, halve-on-failure in addSubBuffer pushes this down
|
|
// so callers' "can this chunk fit via growth?" check stays honest.
|
|
uint64_t next_growth_size_bytes() const {
|
|
return last_growth_size_ > 0 ? last_growth_size_ : per_sub_buffer_capacity_;
|
|
}
|
|
// Whether the pool can still attempt to add a sub-buffer. Flips to
|
|
// false the first time addSubBuffer is refused even at the floor
|
|
// size — eviction callers need this to know whether a future alloc
|
|
// could rescue them, or whether eviction is the only path.
|
|
bool can_grow() const {
|
|
return !growth_disabled_ && per_sub_buffer_capacity_ > 0
|
|
&& (max_total_capacity_bytes_ == 0
|
|
|| total_capacity_bytes() < max_total_capacity_bytes_);
|
|
}
|
|
|
|
// Hard ceiling on total pool capacity (0 = unlimited). Once total capacity
|
|
// reaches this, can_grow() returns false so the streaming driver EVICTS
|
|
// instead of growing. Critical on web: a growth past the wasm heap ceiling
|
|
// is a bad_alloc that -fno-exceptions turns into an uncatchable abort, so
|
|
// the async grow-OOM detection can't save us — we must stop first.
|
|
void setMaxTotalCapacity(uint64_t max_bytes) { max_total_capacity_bytes_ = max_bytes; }
|
|
|
|
// Proactively add a sub-buffer (no allocation). On web this kicks off the
|
|
// async provisional-validation cycle so validated free space appears a
|
|
// frame or two later — letting the streaming driver grow the pool BEFORE
|
|
// fetching a chunk's bytes, instead of fetching, failing the alloc on a
|
|
// not-yet-grown pool, and re-fetching. No-op if growth is pending/disabled.
|
|
bool requestGrowth() { return addSubBuffer(); }
|
|
|
|
// Test-only seam. Production code populates sub-pools lazily through
|
|
// alloc() → addSubBuffer() → wgpuDeviceCreateBuffer; that path needs a
|
|
// real WGPUDevice and is impractical to exercise from a unit test.
|
|
// tests/test_buffer_pool.cpp uses this method to preseed a sub-pool
|
|
// with a known capacity and a fake (non-null) WGPUBuffer handle the
|
|
// allocator only treats as opaque — the free-list bookkeeping never
|
|
// dereferences it. Not for production use.
|
|
void addSubBufferForTesting(WGPUBuffer fake_buffer, uint64_t capacity);
|
|
// Drop fake-handle sub-pools without calling wgpuBufferRelease on
|
|
// them. Tests must call this before the pool destructs (or rely on
|
|
// the FakePoolGuard fixture in test_buffer_pool.cpp), otherwise
|
|
// ~BufferPool → destroy() would dereference the fake handles.
|
|
void clearSubPoolsForTesting();
|
|
|
|
private:
|
|
struct FreeRange { uint64_t offset; uint64_t size; };
|
|
struct SubPool {
|
|
WGPUBuffer buffer = nullptr;
|
|
uint64_t capacity = 0;
|
|
uint64_t used = 0;
|
|
std::vector<FreeRange> free_ranges;
|
|
// Web-only: true between addSubBuffer creating the buffer and the
|
|
// async error scope confirming it didn't OOM. alloc() and the
|
|
// capacity/free tallies skip provisional sub-pools so an
|
|
// unvalidated (possibly invalid) buffer is never handed out.
|
|
bool provisional = false;
|
|
};
|
|
|
|
// Append a new sub-buffer to the pool. Starts at last_growth_size_
|
|
// (initially per_sub_buffer_capacity_) and halves on driver refusal
|
|
// before giving up — many Vulkan drivers cap single VkDeviceMemory
|
|
// allocations at a couple GB (e.g. NVIDIA: maxStorageBufferBindingSize
|
|
// is exactly 2 GB on consumer GeForce cards) or refuse big contiguous
|
|
// allocations once heap is fragmented, but happily grant smaller ones.
|
|
// Halving turns "stop at first refused 2 GB" into "2 GB + 1 GB + …",
|
|
// which on a 4 GB card lets us reach 3 GB total instead of 2 GB.
|
|
// Wrapped in OOM/Validation error scopes so failed attempts don't
|
|
// take the device down. Returns true on success at some size
|
|
// ≥ MIN_SUB_BUFFER_BYTES; false only when even the minimum size is
|
|
// refused, at which point growth_disabled_ latches.
|
|
bool addSubBuffer();
|
|
|
|
#if defined(__EMSCRIPTEN__)
|
|
// Web-only async-growth resolver. Called from the AllowSpontaneous
|
|
// PopErrorScope callback addSubBuffer arms: `failed` true means the
|
|
// provisional sub-buffer OOM'd → drop it and latch growth_disabled_;
|
|
// false means it's good → clear its provisional flag so alloc can use
|
|
// it. Clears growth_pending_ either way.
|
|
void resolveProvisionalGrowth(bool failed);
|
|
#endif
|
|
|
|
std::vector<SubPool> sub_pools_;
|
|
|
|
WGPUInstance instance_ = nullptr;
|
|
WGPUDevice device_ = nullptr;
|
|
WGPUBufferUsage usage_ = 0;
|
|
uint64_t per_sub_buffer_capacity_ = 0;
|
|
uint64_t max_total_capacity_bytes_ = 0; // 0 = unlimited (see can_grow)
|
|
// The largest size addSubBuffer last *succeeded* at, in bytes.
|
|
// Starts at per_sub_buffer_capacity_ (the probe's discovered max)
|
|
// and decays as the driver refuses larger allocations. Future grow
|
|
// attempts start from here rather than re-trying the max every
|
|
// time — once the driver has refused 2 GB, retrying 2 GB on every
|
|
// subsequent grow is wasted work.
|
|
uint64_t last_growth_size_ = 0;
|
|
bool growth_disabled_ = false;
|
|
// Web-only: a provisional sub-buffer is awaiting async OOM validation.
|
|
// Blocks a second concurrent grow so a stalled validation can't spawn
|
|
// a pile of sub-buffers.
|
|
bool growth_pending_ = false;
|
|
std::string label_prefix_;
|
|
};
|
|
|
|
#endif // WGPUBUFFERPOOL_H
|