diff --git a/include/cucascade/data/data_batch.hpp b/include/cucascade/data/data_batch.hpp index 92accdb..ea15b9d 100644 --- a/include/cucascade/data/data_batch.hpp +++ b/include/cucascade/data/data_batch.hpp @@ -31,7 +31,6 @@ #include #include #include -#include #include #include #include @@ -158,17 +157,19 @@ class data_batch : public std::enable_shared_from_this { * @brief Transition from read-only back to idle (release shared lock). * * @param accessor Rvalue reference to the read-only accessor (consumed). - * @return The batch pointer, now in idle state. + * @return The batch pointer, now in idle state. Use `std::ignore = + * data_batch::to_idle(read_handle)` to simply let go of the read accessor. */ - [[nodiscard]] static std::shared_ptr to_idle(read_only_data_batch&& accessor); + static std::shared_ptr to_idle(read_only_data_batch&& accessor); /** * @brief Transition from mutable back to idle (release exclusive lock). * * @param accessor Rvalue reference to the mutable accessor (consumed). - * @return The batch pointer, now in idle state. + * @return The batch pointer, now in idle state. Use `std::ignore = + * data_batch::to_idle(write_handle)` to simply let go of the mutable accessor */ - [[nodiscard]] static std::shared_ptr to_idle(mutable_data_batch&& accessor); + static std::shared_ptr to_idle(mutable_data_batch&& accessor); // -- Non-static transitions (via shared_from_this) -- // The caller's shared_ptr is NOT consumed. These only work when the @@ -182,7 +183,7 @@ class data_batch : public std::enable_shared_from_this { * * @return A read_only_data_batch holding the shared lock. */ - [[nodiscard]] read_only_data_batch to_read_only(); + [[nodiscard]] read_only_data_batch to_read_only() const; /** * @brief Transition from idle to mutable (exclusive lock) without consuming the caller's pointer. @@ -204,7 +205,7 @@ class data_batch : public std::enable_shared_from_this { * @return An optional containing the read-only accessor on success, or * std::nullopt if the lock could not be acquired immediately. */ - [[nodiscard]] std::optional try_to_read_only(); + [[nodiscard]] std::optional try_to_read_only() const; /** * @brief Try to transition from idle to mutable (non-blocking). @@ -216,36 +217,6 @@ class data_batch : public std::enable_shared_from_this { */ [[nodiscard]] std::optional try_to_mutable(); - // -- Locked-to-locked static transitions -- - - /** - * @brief Transition from read-only to mutable (upgrade lock). - * - * Releases the shared lock, then acquires an exclusive lock (may block). - * Waits for all recorded asynchronous readers before acquiring the exclusive lock - * (re-checked under the lock) and exposing mutable access. - * The source accessor is consumed via move. - * NOTE: The transition is not atomic. - * - * @param accessor Rvalue reference to the read-only accessor (consumed). - * @return A mutable_data_batch holding the exclusive lock. - * @throws cucascade::cuda_error if a recorded reader event cannot be synchronized. - * @throws rmm::cuda_error if a reader event's CUDA device cannot be made current. - */ - [[nodiscard]] static mutable_data_batch readonly_to_mutable(read_only_data_batch&& accessor); - - /** - * @brief Transition from mutable to read-only (downgrade lock). - * - * Releases the exclusive lock, then acquires a shared lock (may block). - * The source accessor is consumed via move. - * NOTE: The transition is not atomic. - * - * @param accessor Rvalue reference to the mutable accessor (consumed). - * @return A read_only_data_batch holding the shared lock. - */ - [[nodiscard]] static read_only_data_batch mutable_to_readonly(mutable_data_batch&& accessor); - private: data_batch(uint64_t batch_id, std::unique_ptr data, @@ -275,37 +246,6 @@ class data_batch : public std::enable_shared_from_this { */ void set_data(std::unique_ptr data); - /** - * @brief Record completion of asynchronous work reading the current representation. - * - * GPU batches retain one event for every outstanding reader stream. Non-GPU tiers are a no-op. - * The caller must hold a shared lock while recording so the representation cannot change between - * issuing the read and recording its completion. - * - * @param reader_stream Stream on which work reading the batch was enqueued. - */ - void record_reader_event(rmm::cuda_stream_view reader_stream); - - /** - * @brief Block until all recorded asynchronous readers have completed. - * - * Called while the batch's exclusive lock is held before mutable access is exposed. - */ - void synchronize_reader_events(); - - /** - * @brief Query and recycle completed reader events without blocking. - * - * @return true when no recorded asynchronous reader remains in flight. - * @throws cucascade::cuda_error if an event's query reports a CUDA failure. - */ - [[nodiscard]] bool reader_events_complete(); - - /** - * @brief Destructor-safe form of synchronize_reader_events(). - */ - void synchronize_reader_events_no_throw() noexcept; - struct reader_event_pool { // Events are stored as a pending prefix followed by reusable completed events. This bounds // event creation by the peak number of overlapping reads registered on this CUDA device. @@ -313,28 +253,67 @@ class data_batch : public std::enable_shared_from_this { std::size_t pending_event_count{0}; }; - /** - * @brief Move completed events from the pending prefix back into the reusable pool. - * - * Requires _reader_events_mutex to be held and the pool's CUDA device to be current. - * A query that reports failure is propagated rather than counted as still-pending: a failed - * event never completes, so absorbing it would stall every later mutable acquisition. - * - * @throws cucascade::cuda_error if an event's query reports a CUDA failure. @p pool is left - * unchanged from the failing event onward. - */ - void recycle_completed_reader_events(reader_event_pool& pool); + struct reader_event_pool_map { + // CUDA events are device-associated, so each device needs an independent reusable pool. + std::mutex reader_events_mutex; + std::unordered_map reader_event_pools; + + /** + * @brief Record completion of asynchronous work reading the current representation. + * + * GPU batches retain one event for every outstanding reader stream. Non-GPU tiers are a no-op. + * The caller must hold a shared lock while recording so the representation cannot change + * between issuing the read and recording its completion. + * + * @param reader_stream Stream on which work reading the batch was enqueued. + */ + void record_reader_event(rmm::cuda_stream_view reader_stream); + + /** + * @brief Block until all recorded asynchronous readers have completed. + * + * Called while the batch's exclusive lock is held before mutable access is exposed. + */ + void synchronize_reader_events(); + + /** + * @brief Query and recycle completed reader events without blocking. + * + * @return true when no recorded asynchronous reader remains in flight. + * @throws cucascade::cuda_error if an event's query reports a CUDA failure. + */ + [[nodiscard]] bool reader_events_complete(); + + /** + * @brief Destructor-safe form of synchronize_reader_events(). + */ + void synchronize_reader_events_no_throw() noexcept; + + /** + * @brief Move completed events from the pending prefix back into the reusable pool. + * + * Requires _reader_events_mutex to be held and the pool's CUDA device to be current. + * A query that reports failure is propagated rather than counted as still-pending: a failed + * event never completes, so absorbing it would stall every later mutable acquisition. + * + * @throws cucascade::cuda_error if an event's query reports a CUDA failure. @p pool is left + * unchanged from the failing event onward. + */ + void recycle_completed_reader_events(reader_event_pool& pool) const; + }; - const uint64_t _batch_id; ///< Immutable batch identifier - std::unique_ptr _data; ///< Owned data representation - mutable std::shared_mutex _rw_mutex; ///< Reader-writer mutex - std::atomic _subscriber_count{0}; ///< Atomic subscriber interest count - std::atomic _state{batch_state::idle}; ///< Observable lock state - std::atomic _read_only_count{0}; ///< Count of active read_only_data_batch instances + const uint64_t _batch_id; ///< Immutable batch identifier + std::unique_ptr _data; ///< Owned data representation + mutable std::shared_mutex _rw_mutex; ///< Reader-writer mutex + mutable std::atomic _subscriber_count{0}; ///< Atomic subscriber interest count + mutable std::atomic _state{batch_state::idle}; ///< Observable lock state + mutable std::atomic _read_only_count{ + 0}; ///< Count of active read_only_data_batch instances // CUDA events are device-associated, so each device needs an independent reusable pool. - std::mutex _reader_events_mutex; - std::unordered_map _reader_event_pools; + // mutable is for read_only case which holds `const data_batch`, but is OK because this + // internally uses a separate mutex. + mutable reader_event_pool_map _reader_event_pools; std::unique_ptr _probe; }; @@ -360,16 +339,19 @@ class read_only_data_batch { // -- Named accessor methods -- /** @brief Get the batch identifier. */ - uint64_t get_batch_id() const { return _batch->get_batch_id(); } + [[nodiscard]] uint64_t get_batch_id() const { return _batch->get_batch_id(); } /** @brief Get the memory tier of the held data. */ - memory::Tier get_current_tier() const { return _batch->get_current_tier(); } + [[nodiscard]] memory::Tier get_current_tier() const { return _batch->get_current_tier(); } /** @brief Get a raw pointer to the data representation. */ [[nodiscard]] const idata_representation* get_data() const { return _batch->get_data(); } /** @brief Get a raw pointer to the memory space. */ - memory::memory_space* get_memory_space() const { return _batch->get_memory_space(); } + [[nodiscard]] memory::memory_space* get_memory_space() const + { + return _batch->get_memory_space(); + } /** * @brief Get the writer event from the underlying representation, or nullptr. @@ -418,7 +400,11 @@ class read_only_data_batch { */ void record_reader_event(rmm::cuda_stream_view reader_stream) const { - _batch->record_reader_event(reader_stream); + // Host and disk representations do not expose stream-ordered device memory. + if (_batch->_data == nullptr || _batch->_data->get_current_tier() != memory::Tier::GPU) { + return; + } + _batch->_reader_event_pools.record_reader_event(reader_stream); } /** @@ -499,13 +485,13 @@ class read_only_data_batch { * @param parent Shared pointer to the parent data_batch (moved in). * @param lock Shared lock already acquired on the parent's mutex. */ - read_only_data_batch(std::shared_ptr parent, + read_only_data_batch(std::shared_ptr parent, std::shared_lock lock); // INVARIANT: _batch must be declared before _lock -- destruction order is load-bearing. // When destroyed, _lock releases the shared lock first, then _batch drops the parent // reference. This prevents accessing a destroyed mutex. - std::shared_ptr _batch; ///< Parent lifetime (destroyed second) + std::shared_ptr _batch; ///< Parent lifetime (destroyed second) std::shared_lock _lock; ///< Shared lock (destroyed first) }; @@ -523,16 +509,19 @@ class mutable_data_batch { // -- Read methods (same as read_only) -- /** @brief Get the batch identifier. */ - uint64_t get_batch_id() const { return _batch->get_batch_id(); } + [[nodiscard]] uint64_t get_batch_id() const { return _batch->get_batch_id(); } /** @brief Get the memory tier of the held data. */ - memory::Tier get_current_tier() const { return _batch->get_current_tier(); } + [[nodiscard]] memory::Tier get_current_tier() const { return _batch->get_current_tier(); } /** @brief Get a raw pointer to the data representation. */ - idata_representation* get_data() const { return _batch->get_data(); } + [[nodiscard]] idata_representation* get_data() const { return _batch->get_data(); } /** @brief Get a raw pointer to the memory space. */ - memory::memory_space* get_memory_space() const { return _batch->get_memory_space(); } + [[nodiscard]] memory::memory_space* get_memory_space() const + { + return _batch->get_memory_space(); + } // -- Write methods -- diff --git a/src/data/data_batch.cpp b/src/data/data_batch.cpp index d197852..47ffb4f 100644 --- a/src/data/data_batch.cpp +++ b/src/data/data_batch.cpp @@ -51,7 +51,7 @@ data_batch::~data_batch() // Usually the cache/repository retains the batch and mutable acquisition performs this wait. // This fallback is load-bearing when the final read-only accessor owns the last shared_ptr: the // representation must remain alive until its registered device reads finish. - synchronize_reader_events_no_throw(); + _reader_event_pools.synchronize_reader_events_no_throw(); } uint64_t data_batch::get_batch_id() const { return _batch_id; } @@ -96,7 +96,8 @@ void data_batch::set_data(std::unique_ptr data) _probe->data_replaced(*_data); } -void data_batch::recycle_completed_reader_events(reader_event_pool& pool) +void data_batch::reader_event_pool_map::recycle_completed_reader_events( + reader_event_pool& pool) const { std::size_t index = 0; while (index < pool.pending_event_count) { @@ -114,17 +115,14 @@ void data_batch::recycle_completed_reader_events(reader_event_pool& pool) } } -void data_batch::record_reader_event(rmm::cuda_stream_view reader_stream) +void data_batch::reader_event_pool_map::record_reader_event(rmm::cuda_stream_view reader_stream) { - // Host and disk representations do not expose stream-ordered device memory. - if (_data == nullptr || _data->get_current_tier() != memory::Tier::GPU) { return; } - int reader_device = -1; try { CUCASCADE_CUDA_TRY(::cudaStreamGetDevice(reader_stream.value(), &reader_device)); rmm::cuda_set_device_raii device_guard{rmm::cuda_device_id{reader_device}}; - std::lock_guard lock(_reader_events_mutex); - auto& pool = _reader_event_pools[reader_device]; + std::lock_guard lock(reader_events_mutex); + auto& pool = reader_event_pools[reader_device]; recycle_completed_reader_events(pool); if (pool.pending_event_count == pool.events.size()) { @@ -146,10 +144,10 @@ void data_batch::record_reader_event(rmm::cuda_stream_view reader_stream) } } -void data_batch::synchronize_reader_events() +void data_batch::reader_event_pool_map::synchronize_reader_events() { - std::lock_guard lock(_reader_events_mutex); - for (auto& [device_id, pool] : _reader_event_pools) { + std::lock_guard lock(reader_events_mutex); + for (auto& [device_id, pool] : reader_event_pools) { rmm::cuda_set_device_raii device_guard{rmm::cuda_device_id{device_id}}; for (std::size_t index = 0; index < pool.pending_event_count; ++index) { pool.events[index].synchronize(); @@ -158,10 +156,10 @@ void data_batch::synchronize_reader_events() } } -bool data_batch::reader_events_complete() +bool data_batch::reader_event_pool_map::reader_events_complete() { - std::lock_guard lock(_reader_events_mutex); - for (auto& [device_id, pool] : _reader_event_pools) { + std::lock_guard lock(reader_events_mutex); + for (auto& [device_id, pool] : reader_event_pools) { rmm::cuda_set_device_raii device_guard{rmm::cuda_device_id{device_id}}; recycle_completed_reader_events(pool); if (pool.pending_event_count != 0) { return false; } @@ -169,10 +167,10 @@ bool data_batch::reader_events_complete() return true; } -void data_batch::synchronize_reader_events_no_throw() noexcept +void data_batch::reader_event_pool_map::synchronize_reader_events_no_throw() noexcept { - std::lock_guard lock(_reader_events_mutex); - if (_reader_event_pools.empty()) { return; } + std::lock_guard lock(reader_events_mutex); + if (reader_event_pools.empty()) { return; } int original_device = -1; cudaError_t const get_device_status = ::cudaGetDevice(&original_device); @@ -180,7 +178,7 @@ void data_batch::synchronize_reader_events_no_throw() noexcept // Destroy every event under the device where it was created. Clearing the pools here also makes // the unordered_map's later member destruction independent of the caller's current device. - for (auto& [device_id, pool] : _reader_event_pools) { + for (auto& [device_id, pool] : reader_event_pools) { CUCASCADE_ASSERT_CUDA_SUCCESS(::cudaSetDevice(device_id)); for (std::size_t index = 0; index < pool.pending_event_count; ++index) { CUCASCADE_ASSERT_CUDA_SUCCESS(::cudaEventSynchronize(pool.events[index].get())); @@ -188,16 +186,16 @@ void data_batch::synchronize_reader_events_no_throw() noexcept pool.pending_event_count = 0; pool.events.clear(); } - _reader_event_pools.clear(); + reader_event_pools.clear(); if (restore_original_device) { CUCASCADE_ASSERT_CUDA_SUCCESS(::cudaSetDevice(original_device)); } } // ========== Static transition methods ========== -std::shared_ptr data_batch::to_idle(read_only_data_batch&& accessor) +std::shared_ptr data_batch::to_idle(read_only_data_batch&& accessor) { - auto ptr = accessor._batch; + std::shared_ptr ptr = accessor._batch; { auto _ = std::move(accessor); } // destroy accessor, releasing shared lock @@ -215,10 +213,10 @@ std::shared_ptr data_batch::to_idle(mutable_data_batch&& accessor) // ========== Non-static transition methods ========== -read_only_data_batch data_batch::to_read_only() +read_only_data_batch data_batch::to_read_only() const { - auto self = shared_from_this(); - std::shared_lock lock(_rw_mutex); + std::shared_ptr self = shared_from_this(); + std::shared_lock lock(_rw_mutex); return read_only_data_batch(std::move(self), std::move(lock)); } @@ -230,62 +228,34 @@ mutable_data_batch data_batch::to_mutable() // could deadlock. Readers may record again between the drain and the acquisition, so re-check // under the lock and retry. while (true) { - synchronize_reader_events(); + _reader_event_pools.synchronize_reader_events(); std::unique_lock lock(_rw_mutex); - if (reader_events_complete()) { return mutable_data_batch(std::move(self), std::move(lock)); } + if (_reader_event_pools.reader_events_complete()) { + return mutable_data_batch(std::move(self), std::move(lock)); + } } } -std::optional data_batch::try_to_read_only() +std::optional data_batch::try_to_read_only() const { - std::shared_lock lock(_rw_mutex, std::try_to_lock); + std::shared_lock lock(_rw_mutex, std::try_to_lock); if (!lock.owns_lock()) { return std::nullopt; } - auto self = shared_from_this(); + std::shared_ptr self = shared_from_this(); return read_only_data_batch(std::move(self), std::move(lock)); } std::optional data_batch::try_to_mutable() { - std::unique_lock lock(_rw_mutex, std::try_to_lock); + std::unique_lock lock(_rw_mutex, std::try_to_lock); if (!lock.owns_lock()) { return std::nullopt; } - if (!reader_events_complete()) { return std::nullopt; } + if (!_reader_event_pools.reader_events_complete()) { return std::nullopt; } auto self = shared_from_this(); return mutable_data_batch(std::move(self), std::move(lock)); } -// ========== Locked-to-locked static transitions ========== - -mutable_data_batch data_batch::readonly_to_mutable(read_only_data_batch&& accessor) -{ - auto ptr = accessor._batch; - { - // destructor decrements _read_only_count, releases the shared lock and sets state to idle - auto _ = std::move(accessor); // move into temporary, destroyed at } - } - // Same drain-then-recheck pattern as to_mutable(). - while (true) { - ptr->synchronize_reader_events(); - std::unique_lock lock(ptr->_rw_mutex); - if (ptr->reader_events_complete()) { - return mutable_data_batch(std::move(ptr), std::move(lock)); - } - } -} - -read_only_data_batch data_batch::mutable_to_readonly(mutable_data_batch&& accessor) -{ - auto ptr = accessor._batch; - { - // destructor frees the exclusive lock and sets state to idle - auto _ = std::move(accessor); // move into temporary, destroyed at } - } - std::shared_lock lock(ptr->_rw_mutex); - return read_only_data_batch(std::move(ptr), std::move(lock)); -} - // ========== read_only_data_batch ========== -read_only_data_batch::read_only_data_batch(std::shared_ptr parent, +read_only_data_batch::read_only_data_batch(std::shared_ptr parent, std::shared_lock lock) : _batch(std::move(parent)), _lock(std::move(lock)) { diff --git a/test/data/test_data_batch.cpp b/test/data/test_data_batch.cpp index 67099f8..dd8e3e5 100644 --- a/test/data/test_data_batch.cpp +++ b/test/data/test_data_batch.cpp @@ -256,18 +256,6 @@ TEST_CASE("data_batch mutable to readonly through idle", "[data_batch]") auto ro = idle->to_read_only(); REQUIRE(ro.get_batch_id() == 1); } - -TEST_CASE("data_batch readonly to mutable through idle", "[data_batch]") -{ - auto data = std::make_unique(memory::Tier::GPU, 1024); - auto batch = data_batch::make(1, std::move(data)); - - auto ro = batch->to_read_only(); - auto idle = data_batch::to_idle(std::move(ro)); - auto rw = idle->to_mutable(); - REQUIRE(rw.get_batch_id() == 1); -} - // ============================================================================= // Destruction order safety (TEST-02) // ============================================================================= @@ -1265,7 +1253,10 @@ TEST_CASE("mutable acquisition waits for all recorded asynchronous GPU readers", try { std::optional mutable_batch; if (upgrade_from_read_only) { - mutable_batch.emplace(data_batch::readonly_to_mutable(std::move(*upgrade_reader))); + // Accessors no longer support a locked-to-locked upgrade. Release the shared + // accessor, then acquire exclusive access through the retained batch handle. + upgrade_reader.reset(); + mutable_batch.emplace(batch->to_mutable()); } else { mutable_batch.emplace(batch->to_mutable()); } @@ -1340,7 +1331,7 @@ TEST_CASE("reader event registration is a no-op for non-GPU batches", "[data_bat auto reader = batch->to_read_only(); auto invalid_stream = rmm::cuda_stream_view{reinterpret_cast(std::uintptr_t{1})}; REQUIRE_NOTHROW(reader.record_reader_event(invalid_stream)); - batch = data_batch::to_idle(std::move(reader)); + std::ignore = data_batch::to_idle(std::move(reader)); REQUIRE(batch->try_to_mutable().has_value()); }; @@ -1359,7 +1350,7 @@ TEST_CASE("completed reader events are recycled across mutable acquisitions", auto reader = batch->to_read_only(); reader.record_reader_event(reader_stream.view()); reader_stream.synchronize(); - batch = data_batch::to_idle(std::move(reader)); + std::ignore = data_batch::to_idle(std::move(reader)); auto mutable_batch = batch->try_to_mutable(); REQUIRE(mutable_batch.has_value()); @@ -1384,7 +1375,7 @@ TEST_CASE("reader event pools remain device-local across representation replacem auto reader = batch->to_read_only(); reader.record_reader_event(reader_stream.view()); reader_stream.synchronize(); - batch = data_batch::to_idle(std::move(reader)); + std::ignore = data_batch::to_idle(std::move(reader)); } auto mutable_batch = batch->to_mutable(); @@ -1397,7 +1388,7 @@ TEST_CASE("reader event pools remain device-local across representation replacem auto reader = batch->to_read_only(); REQUIRE_NOTHROW(reader.record_reader_event(reader_stream.view())); reader_stream.synchronize(); - batch = data_batch::to_idle(std::move(reader)); + std::ignore = data_batch::to_idle(std::move(reader)); } REQUIRE(batch->try_to_mutable().has_value()); @@ -1478,7 +1469,7 @@ TEST_CASE("reader event pool sustains cycles with a pending head and completed t reader.record_reader_event(fast_stream.view()); } fast_stream.synchronize(); - batch = data_batch::to_idle(std::move(reader)); + std::ignore = data_batch::to_idle(std::move(reader)); } REQUIRE_FALSE(batch->try_to_mutable().has_value()); @@ -1503,7 +1494,7 @@ TEST_CASE("record_reader_event accepts the legacy default stream", auto reader = batch->to_read_only(); CUCASCADE_CUDA_TRY(::cudaMemsetAsync(scratch.data(), 0x5A, scratch.size(), nullptr)); REQUIRE_NOTHROW(reader.record_reader_event(rmm::cuda_stream_default)); - batch = data_batch::to_idle(std::move(reader)); + std::ignore = data_batch::to_idle(std::move(reader)); } CUCASCADE_CUDA_TRY(::cudaStreamSynchronize(nullptr)); @@ -1542,50 +1533,6 @@ TEST_CASE("~data_batch waits for recorded readers when the final accessor drops REQUIRE(read_retired.load(std::memory_order_acquire)); } -// ============================================================================= -// Locked-to-locked transition tests -// ============================================================================= - -TEST_CASE("data_batch readonly_to_mutable", "[data_batch]") -{ - auto data = std::make_unique(memory::Tier::GPU, 1024); - auto batch = data_batch::make(1, std::move(data)); - - auto ro = batch->to_read_only(); - auto mut = data_batch::readonly_to_mutable(std::move(ro)); - REQUIRE(mut.get_batch_id() == 1); - - auto idle = data_batch::to_idle(std::move(mut)); - REQUIRE(idle->get_state() == batch_state::idle); -} - -TEST_CASE("data_batch mutable_to_readonly", "[data_batch]") -{ - auto data = std::make_unique(memory::Tier::GPU, 1024); - auto batch = data_batch::make(1, std::move(data)); - - auto mut = batch->to_mutable(); - auto ro = data_batch::mutable_to_readonly(std::move(mut)); - REQUIRE(ro.get_batch_id() == 1); - - auto idle = data_batch::to_idle(std::move(ro)); - REQUIRE(idle->get_state() == batch_state::idle); -} - -TEST_CASE("data_batch full cycle: idle -> ro -> mutable -> ro -> idle", "[data_batch]") -{ - auto data = std::make_unique(memory::Tier::GPU, 1024); - auto batch = data_batch::make(1, std::move(data)); - - auto ro1 = batch->to_read_only(); - auto mut = data_batch::readonly_to_mutable(std::move(ro1)); - auto ro2 = data_batch::mutable_to_readonly(std::move(mut)); - auto idle = data_batch::to_idle(std::move(ro2)); - - REQUIRE(idle->get_state() == batch_state::idle); - REQUIRE(idle->get_batch_id() == 1); -} - // ============================================================================= // RAII lifecycle tests: _read_only_count tracking and destructor state transitions // =============================================================================