Skip to content

Add a cell-cache executor for batched generation - #22234

Draft
kiymetakdemir wants to merge 3 commits into
pytorch:add-runnerfrom
kiymetakdemir:cell-executor-pr
Draft

Add a cell-cache executor for batched generation#22234
kiymetakdemir wants to merge 3 commits into
pytorch:add-runnerfrom
kiymetakdemir:cell-executor-pr

Conversation

@kiymetakdemir

Copy link
Copy Markdown
Contributor

Implements the Executor seam over the cell KV cache. Backend-neutral: it reaches the cache through CacheBuilderRegistry and BatchControl, with the backend id, cache kind and cache-key option as configuration, so any backend registering a cell cache is served. A session is one cache sequence, and a batch is one forward carrying every input's tokens end to end on a single axis, with the cache's mask keeping the sequences apart. SessionIds are monotonic and never reissued, mapped onto cache sequence ids that do recycle; the cache frees an id once a sequence's last cell goes, which the seam forbids for a SessionId.

Read build_step first; it holds the reasoning. It flattens a batch into tokens, positions and sequence ids in one pass so entry i of each names the same token, which is how the cache pairs them when placing cells and building the mask. Sequence lengths come from the cache and do not move as inputs are laid down, so a per-sequence cursor carries the batch's own
writes.

The method loads on the first execute() rather than in create(), because a delegate may bind per-thread state as it initializes, which happens during that load, and construction is the only entry point that does not run on the engine thread.

@pytorch-bot

pytorch-bot Bot commented Aug 27, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/22234

Note: Links to docs will display an error until the docs builds have been completed.

✅ You can merge normally! (1 Unrelated Failure)

As of commit cb15008 with merge base 5c11944 (image):

FLAKY - The following job failed but was likely due to flakiness present on trunk:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Aug 27, 2026
// The sampler reduces in place, so copy rather than overwrite the
// model's output.
std::vector<float> scores(begin, begin + vocab);
Sampler sampler(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Don't construct a new sampler on each execute.

We should have session_id -> SessionInfo map

And the SessionInfo can have a sampler field on it that gets set during set_sampling_params or something like that. We also don't need to do all this seed mixing.

We can just continue using the sampler object.

@kiymetakdemir kiymetakdemir Aug 28, 2026

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.

Makes sense, thank you! Sampler needs vocab_size at construction and set_sampling doesn't have it. The export script can save that to metadata. I guess the other option is building the sampler lazily on the first draw.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We don't need to build sampler lazily.

The exeuctor/model should know its vocab size, and can save in private field after construction that sampler construction reads from


// Spread one seed over positions so a session's neighbouring tokens draw
// unrelated streams.
std::uint64_t mix(std::uint64_t seed, std::int64_t position) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do we need mix? See comment below on sampler

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.

Removing it.

out.outputs.resize(batch.inputs.size());

// Ahead of the step: a refused load must claim no cell.
if (!ensure_method_loaded()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why are we loadign in execute?

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.

It was a work around for the stream issue I got. From what I understand, the caller constructs the executor and passes it to Runner by reference, so the executor exists before the Runner, and the Runner creates the engine thread. That means create() runs on the caller's thread while every execute() runs on the engine thread.
Loading in create() registers it on the caller's thread, so the first execute() fails with "There is no Stream(gpu, N) in current thread".
But again thinking about it, a start() on Executor, called once on the engine thread before any batch should solve the issue.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I thought your other PR where you do cache building lazily is what fixed stream issue?

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.

They are actually two different stream issues; one is the model's own stream created on the wrong thread, the other is the KV pool using the default stream instead of the one it was handed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What is a minimal repro for the second model stream on wrong thread?

That seems like a bigger UI issue that I don't want to paper over with a short term fix.

make_tensor_ptr({1, static_cast<int>(step->tokens.size())}, step->tokens);
auto positions = make_tensor_ptr(
{static_cast<int>(step->positions.size())}, step->positions);
const auto result = module_->execute(config_.method, {tokens, positions});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What communicates logit positions to the model?

const auto logits = result->at(0).toTensor();

for (std::size_t i = 0; i < batch.inputs.size(); ++i) {
const int row = step->logit_indices[i];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

How are we communicating logit indices to the model in lm_head project?

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.

We don't, we talked about changing the model forward but didn't do it yet, should I do it as a follow up pr?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We can do as follow-up PR, but I was very surprised it wasn't already being done at export stage

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.

I guess I just focused on single sequence that time

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

But even for single sequence, I expected last token selection, but it seems that isn't being done

Comment thread extension/llm/batching/cell_executor.h Outdated
// limits the table cannot honor and open_session() holds the count --
// exhaustion is kept unreachable rather than handled.
int max_sessions = 0;
int max_session_tokens = 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is max_session_tokens tied to some limit in cacheconfig?

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.

We are checking if max_sessions * max_session_tokens > cacheconfig.capacity and reject if it exceeds capacity.

make_tensor_ptr({1, static_cast<int>(step->tokens.size())}, step->tokens);
auto positions = make_tensor_ptr(
{static_cast<int>(step->positions.size())}, step->positions);
const auto result = module_->execute(config_.method, {tokens, positions});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What is the shape of result?

Is it logits per token, or only final logits?

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.

It is logits per token

ET_CHECK_MSG(false, "CellExecutor: unsupported logits dtype");
}
} ctx;
ET_SWITCH_THREE_TYPES(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we not reuse sample_from_logits?

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.

We can but sample_from_logits assumes single-sequence decoding. I can either add a row parameter defaulting to last, or pass a one-row tensor view so the offset branch is skipped.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is it difficult to call sample_from_logits per sequence in batch?

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.

No, I can do that

Comment thread extension/llm/batching/cell_executor.h Outdated
// (backend_id, cache_kind).
static std::unique_ptr<CellExecutor> create(
std::unique_ptr<Module> module,
Config config);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Just have params in create directly? Do we need another Config struct?

Config has a CacheConfig inside of it, but CacheConfig is fixed by Module and the model archetecture. Can we just construct from Module? Do we have shareable helpers for that?

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.

CacheConfig has parameters the module doesn't carry: capacity, kv_storage_dtype, initial_capacity; so create can't build it from the module alone, the application supplies those from its own flags.

But the executor just reads one field of it (capacity, for the max_sessions × max_session_tokens check) and forwards the rest to the cache builder.

Would it make more sense for the application to build the cache and pass it in? The executor would take capacity from the controller, and what create needs would be max_sessions, max_session_tokens, backend_id, and method.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think there are two parts to cache config. There are parts that are specific to the model and not actually configurable (these are read from pte metadata), and then there are parts that are configurable.

Maybe CacheConfig is a class with methods on it and helper that builds it from module?

auto config = CacheConfig::create(model) or something.
config.set_capacity(max_sessions × max_session_tokens)

What are your thoughts?

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.

Makes sense to me, especially passing max_sessions, max_session_tokens and capacity separately and then comparing them looked redundant

Comment thread extension/llm/batching/executor.h Outdated
// once before any other call, on that thread.
//
// false = the executor cannot run.
virtual bool start() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm still not understanding this threading issue you're hitting, and why you need this. What is the exact issue?

Comment thread extension/llm/batching/cell_executor.h Outdated
std::string method = "forward");

// Loads the method, naming the cache to the backend. The delegate binds
// per-thread state as it initializes, which happens during that load.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code pointer for this?

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.

Module::load_method
→ Method::init runtime/executor/method.cpp:883
→ BackendDelegate::Init runtime/executor/method.cpp:974
→ backend->init(...) runtime/executor/method.cpp:123
→ new (handle) MLXHandle() MLXBackend.cpp:230
→ new_stream(Device::gpu) MLXBackend.cpp:194

}

std::optional<Token> CellExecutor::sample_row(
::executorch::aten::Tensor& logits,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we use the non-aten tensor?

int max_sessions,
int max_session_tokens,
int kv_dtype,
std::string backend_id,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Doesn't module know the backend?

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.

I now read it from the program and dropped the parameter, rejecting a method spanning more than one backend.

if (!cfg.ok()) {
return nullptr;
}
cfg->capacity = max_sessions * max_session_tokens;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

How does capacity differ from initial_capacity?

If capacity dynamically grows, why do we need to set it to max_sessions * max_session_tokens?

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.

capacity is the ceiling so it doesn't grow, initial_capacity is the starting allocation. I set it to the product because that's the most the executor can ever need.

logits_sizes[logits_sizes.size() - 1]));
}

bool CellExecutor::start() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this looks like it should be called load?

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.

Removed this after the mlx backend fix.

// before the step, so the batch's own writes live here.
std::unordered_map<std::int32_t, int> cursor;

for (const Input& input : batch.inputs) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Does model have a max accepted size via prefill chunk size or something?

Are we overflowing that limit?

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

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants