Skip to content

Add transformer-gpu state manager that keeps transformer states on the GPU - #282

Open
larissakl wants to merge 6 commits into
onnx-device-memoryfrom
transformer-gpu-state-manager
Open

larissakl wants to merge 6 commits into
onnx-device-memoryfrom
transformer-gpu-state-manager

Conversation

@larissakl

Copy link
Copy Markdown
Contributor

This PR adds a transformer state manager that keeps the states, e.g. the KV cache, in GPU memory. It behaves like the transformer state manager, including the parameters max-history and always-include-first-token-state. The search, label scorers and LMs are unchanged.

Each state is stored as a DeviceVector, a CompressedVector whose data lives in a block of a GPU memory pool. This keeps the HistoryState interface unchanged. mergeStates assembles the batched state inputs with a small CUDA kernel and passes them to the session as device tensors. With #281, the session then keeps the state outputs on the GPU, and splitStates copies them into new pool blocks. Outputs that arrive in host memory, e.g. from a model without state inputs, are uploaded first. This removes the host-side merge and split and the copies of all states between host and GPU in every search step.
The pool only frees its memory when the state manager is destroyed, freed blocks are reused. GPU memory therefore follows the peak number of states that are alive at the same time. For label scorers, this is bounded by their caches (max-cached-score-vectors) and for the OnnxRecurrentLanguageModel by the number of live LM histories (as with the transformer state manager in host memory).

Depends on #281.

@larissakl
larissakl marked this pull request as ready for review October 16, 2026 12:13
@larissakl

Copy link
Copy Markdown
Contributor Author

My proposal to make RASR use the GPU state manager automatically whenever the session runs with the CUDA execution provider, such that setting state-manager.type = transformer in the config is enough:

  • In Onnx::Session (in PR ONNX: support tensors in CUDA device memory, IO binding and CUDA provider options #281), add a getter like useCudaExecutionProvider(), so others can "ask" whether the session runs on CUDA
  • Add an extra argument to Nn::Module::createStateManager() saying whether the session uses CUDA and construct either the TransformerGpuStateManager or the OnnxTransformerStateManager accordingly
  • In StateManagedOnnxLabelScorer, the state manager then needs to be created in the constructor body after the model is loaded
  • In OnnxRecurrentLanguageModel, the state manager can be passed to the base class Lm::RecurrentLanguageModel in its constructor and e.g. a protected setter is added there

What do you think about that? @SimBe195 @curufinwe

Comment thread src/Onnx/StateCopier.cc Outdated
}
}

std::vector<float const*> StateCopier::deviceData(std::vector<Value> const& tensors) {

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 upload of host tensors into stagingBuffer_ never runs because mergeStates always feeds device tensors and both users (StateManagedOnnxLabelScorer and OnnxRecurrentLanguageModel) run the session with default output locations. So Session::run already puts the state outputs on the device. Maybe remove the staging code and stagingBuffer_ and instead just require that the tensors are on device.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right that at the moment the staging code is never used and I will therefore remove it. However, the case does occur for label scorers with an initializer model that has no state inputs, e.g. the prefix LM prefill in the PrefixSpeechLmOnnxLabelScorer. We will need to run the initializer there with device output locations for the state outputs when the state manager keeps its states on device.

Comment thread src/Onnx/DeviceVector.hh Outdated
}

// Abort with a message if `status` (a cudaError_t) is an error
void checkCudaStatus(int status, char const* what);

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.

This header is anyway only compiled with MODULE_CUDA active, so you can include <cuda_runtime.h> and use cudaError_t directly. This also allows getting rid of the checkCuda forwarding wrapper in DeviceVector.cc and the casts.

Comment thread src/Onnx/StateCopyKernels.hh Outdated
* For each task copy `stepSize` floats between block and tensor. Block element i is tensor element
* `blockOffsets[i / blockSize] + i % blockSize`. `tasks`/`blockOffsets` are device pointers, returns a cudaError_t.
*/
int launchBlockCopy(BlockCopyTask const* tasks,

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.

Here you could also include <cuda_runtime.h> and use cudaError_t directly as return type instead of an int (similar as checkCudaStatus in DeviceVector.hh).

Comment thread src/Onnx/StateCopyKernels.hh Outdated
size_t blockSize,
size_t stepSize,
BlockCopyDirection direction,
void* stream);

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.

This can be a cudaStream_t instead of void*. For Session::sharedCudaStream() it makes sense to return void* since that must also be compilable without MODULE_CUDA but the cast back to cudaStream_t is repeated in three local sharedStream helper functions (DeviceVector.cc:33, StateCopier.cc:28 and TransformerGpuStateManger.cc:87). This could be unified via one inline sharedStream helper in DeviceVector.hh.

Comment thread src/Onnx/TransformerGpuStateManager.cc Outdated
if (not alwaysIncludeFirstTokenState_ or p != 0ul) {
idx += prefix_offset + p;
}
auto const* state = dynamic_cast<DeviceVector const*>(prefix_states[idx]->at(v).get());

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.

This dynamic cast runs in the innermost loop which can come out to hundreds of thousands of casts per step with large history or batch size. Prefer static_cast instead. If you want the check, maybe do it only on the first state.

Comment thread src/Onnx/StateCopyKernels.cu Outdated
return cudaSuccess;
}
bool const toTensor = direction == BlockCopyDirection::BlockToTensor;
for (size_t start = 0ul; start < numTasks; start += kMaxGridSize) {

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.

This loop is never going to run multiple iterations. That would mean that a single variable has over 2^30 tasks which is unrealistic. You can just get rid of the loop and do a single launch.

Comment thread src/Onnx/DeviceVector.cc Outdated

float DeviceVector::get(size_t pos) const {
require_lt(pos, size_);
return toHost()[pos];

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.

This copies the full vector to host just to read one float. Do a single-element cudaMemcpy from data_ + pos instead.

Comment thread src/Onnx/TransformerGpuStateManager.cc Outdated
StateVariables const& vars,
std::vector<size_t>& suffix_lengths,
std::vector<Value> const& state_tensors,
Nn::CompressedVectorFactory<float> const& vector_factory) {

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.

vector_factory is not used, so you could leave it unnamed here (like in initialState).

Comment thread src/Onnx/TransformerGpuStateManager.cc Outdated
auto& tasks = batches.back().tasks;
float const* base = bases[v];

size_t const timeAxis = timeAxisOf(vars[v]);

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.

timeAxisOf(vars[v]) is computed twice per variable (first time in line 233). It could be saved in StepLayout instead.

Comment thread src/Nn/Module.cc
Comment on lines +278 to +284
#ifdef MODULE_CUDA
return std::unique_ptr<AbstractStateManager<Onnx::Value, Onnx::OnnxStateVariable>>(
new Onnx::TransformerGpuStateManager(config));
#else
Core::Application::us()->criticalError("State manager type transformer-gpu requires RASR compiled with MODULE_CUDA");
return nullptr;
#endif

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.

Instead of the criticalError + nullptr return here, you can conditionally only register "transformer-gpu" if MODULE_CUDA is enabled. Then the value is rejected by the choice parser already. But this anyway should go away when we adopt the backend auto-selection.

@SimBe195

SimBe195 commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator

The code in this PR contains a wild mix of snake_case and camelCase naming, probably because some parts were copied from the CPU state manager and other parts written from scratch. It would be better to make it consistently camelCase since that's the usual convention in RASR.

Also, there are several usages of the term "block" in different contexts which mean different things. It can be a pool block, a contiguous span of data in a tensor or a CUDA thread block. This can make it confusing to follow. I'd prefer some more distinctive names.

Comment thread src/Onnx/TransformerGpuStateManager.cc Outdated
HistoryState result;
result.reserve(vars.size());
for (size_t v = 0ul; v < vars.size(); ++v) {
result.emplace_back(new DeviceVector(nullptr, 0ul));

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.

Please prefer smart pointers instead of raw new. Same thing also further down on line 268.

@SimBe195

SimBe195 commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator

In RecurrentLanguageModel if dump-inputs is enabled it will try to save device tensors which will lead to a crash.

@SimBe195

SimBe195 commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator

My proposal to make RASR use the GPU state manager automatically whenever the session runs with the CUDA execution provider, such that setting state-manager.type = transformer in the config is enough:
...
What do you think about that? @SimBe195 @curufinwe

I like the approach of having a getter function in Onnx::Session. This can even be static, so just static bool usesCudaExecutionProvider(Core::Configuration const& config) which reads the value from config (and from whether MODULE_CUDA is active). This way, the decision can be made before creating a concrete session object. We could also think about using an execution provider type enum instead of just a bool for CUDA to account for different providers in the future.

I would also keep explicit choice values which force the state manager For example one plain "transformer" which auto-selects and "transformer-cpu"/"transformer-cuda" which force a selection. And make sure that "-16bit" and "-8bit" variants don't switch since they are not currently supported on GPU. In fact, maybe a warning should be logged if state-compression is configured since that gets ignored by the GPU state manager.

This branch has not been deployed

No deployments
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.

2 participants