Skip to content

Commit b294f72

Browse files
committed
LLM runner: select I/O contracts and accept custom buffer allocators
Choose the registered IOManager and its logits, method names and step sizes, rejecting unknown contracts and invalid metadata. Load and reset the selected methods; route prompt tails to decode with a side-effect-free capacity check. Preserve module and I/O-buffer lifetimes and expose a backend-neutral allocator hook.
1 parent 5414fbb commit b294f72

14 files changed

Lines changed: 480 additions & 74 deletions

‎extension/llm/runner/constants.h‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ inline constexpr auto kEnableDynamicShape = "enable_dynamic_shape";
1414
inline constexpr auto kBosId = "get_bos_id";
1515
inline constexpr auto kEosIds = "get_eos_ids";
1616
inline constexpr auto kMaxSeqLen = "get_max_seq_len";
17+
// Names the I/O contract of the program's methods,
18+
// which selects the IOManager that drives them.
19+
inline constexpr auto kIoContract = "get_io_contract";
1720
inline constexpr auto kMaxContextLen = "get_max_context_len";
1821
inline constexpr auto kVocabSize = "get_vocab_size";
1922
inline constexpr auto kActivationDtype = "get_activation_dtype";

‎extension/llm/runner/io_manager/io_manager.h‎

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@
88

99
#pragma once
1010

11+
#include <functional>
12+
#include <memory>
13+
#include <string>
14+
#include <unordered_map>
15+
#include <unordered_set>
16+
#include <utility>
17+
1118
#include <executorch/extension/module/module.h>
1219
#include <executorch/extension/tensor/tensor.h>
1320

@@ -236,13 +243,162 @@ class ET_EXPERIMENTAL IOManager {
236243
return update_decode(model_outputs, "forward");
237244
}
238245

246+
/**
247+
* @brief The logits among a method's outputs, as the sampler reads them.
248+
*
249+
* The default takes a method that returns only its logits. An IOManager for
250+
* a model that returns more (e.g. its updated cache) picks them out, and may
251+
* hand back a converted copy (e.g. dequantized).
252+
*
253+
* @param model_outputs The outputs of the method just executed.
254+
* @param method The method that produced them.
255+
* @return The logits tensor.
256+
*/
257+
virtual runtime::Result<executorch::aten::Tensor> logits(
258+
const std::vector<runtime::EValue>& model_outputs,
259+
const std::string& method) {
260+
(void)method;
261+
if (model_outputs.size() != 1 || !model_outputs[0].isTensor()) {
262+
ET_LOG(
263+
Error,
264+
"Expected the method to return only its logits tensor, got %zu outputs.",
265+
model_outputs.size());
266+
return runtime::Error::InvalidState;
267+
}
268+
return model_outputs[0].toTensor();
269+
}
270+
271+
/**
272+
* @brief The most tokens one call of `method` takes, or -1 for no limit of
273+
* the IOManager's own (e.g. a fixed-size prompt step).
274+
*/
275+
virtual int64_t max_tokens_per_step(const std::string& method) const {
276+
(void)method;
277+
return -1;
278+
}
279+
280+
virtual int64_t max_tokens_per_step(
281+
const std::string& method,
282+
int64_t start_pos) const {
283+
(void)start_pos;
284+
return max_tokens_per_step(method);
285+
}
286+
287+
/**
288+
* @brief For an encoder-decoder contract: route `encoder_method`'s outputs
289+
* into the decode step's fixed inputs. Call once after loading, then after
290+
* each run of the encoder pass its outputs to `update_encoder`.
291+
*/
292+
ET_NODISCARD virtual runtime::Error load_encoder(
293+
const std::string& encoder_method) {
294+
(void)encoder_method;
295+
return runtime::Error::NotSupported;
296+
}
297+
298+
ET_NODISCARD virtual runtime::Error update_encoder(
299+
const std::vector<runtime::EValue>& encoder_outputs) {
300+
(void)encoder_outputs;
301+
return runtime::Error::NotSupported;
302+
}
303+
304+
/**
305+
* @brief The prefill and decode method names this IOManager drives, given
306+
* the methods the program has. Empty strings leave the runner's defaults.
307+
*/
308+
virtual std::pair<std::string, std::string> method_names(
309+
const std::unordered_set<std::string>& available) const {
310+
(void)available;
311+
return {"", ""};
312+
}
313+
239314
private:
240315
/**
241316
* @brief Reference to the Module used for method metadata and execution.
242317
*/
243318
ET_MODULE_NAMESPACE::Module& module_;
244319
};
245320

321+
using IOManagerFactory =
322+
std::function<std::unique_ptr<IOManager>(ET_MODULE_NAMESPACE::Module&)>;
323+
324+
/**
325+
* @brief IOManagers by the I/O contract they speak.
326+
*
327+
* A program names its contract in the `get_io_contract` constant method; the
328+
* runner builds the IOManager registered for it, or the default IOManager when
329+
* the program names none.
330+
*/
331+
inline std::unordered_map<std::string, IOManagerFactory>&
332+
io_manager_registry() {
333+
static std::unordered_map<std::string, IOManagerFactory> registry;
334+
return registry;
335+
}
336+
337+
inline bool register_io_manager(
338+
const std::string& contract,
339+
IOManagerFactory factory) {
340+
return io_manager_registry().emplace(contract, std::move(factory)).second;
341+
}
342+
343+
inline runtime::Result<std::unique_ptr<IOManager>> make_io_manager(
344+
ET_MODULE_NAMESPACE::Module& module,
345+
const std::string& contract) {
346+
if (!contract.empty()) {
347+
auto it = io_manager_registry().find(contract);
348+
if (it != io_manager_registry().end()) {
349+
auto manager = it->second(module);
350+
if (!manager) {
351+
return runtime::Error::InvalidState;
352+
}
353+
return manager;
354+
}
355+
ET_LOG(
356+
Error,
357+
"No IOManager registered for I/O contract '%s'.",
358+
contract.c_str());
359+
return runtime::Error::NotSupported;
360+
}
361+
return std::make_unique<IOManager>(module);
362+
}
363+
364+
/// Registers the IOManagers this library implements. Idempotent.
365+
void register_builtin_io_managers();
366+
367+
/**
368+
* @brief Memory for the I/O buffers an IOManager owns.
369+
*
370+
* A backend whose device reads graph I/O from memory it can map (for example
371+
* QNN's rpcmem) registers an allocator, so the device uses these buffers in
372+
* place instead of copying them on every call. Without one, buffers come from
373+
* the heap.
374+
*/
375+
class ET_EXPERIMENTAL IOBufferAllocator {
376+
public:
377+
virtual ~IOBufferAllocator() = default;
378+
/// A block of at least `nbytes`, or nullptr to fall back to the heap.
379+
virtual void* allocate(size_t nbytes, size_t alignment) = 0;
380+
virtual void deallocate(void* block) = 0;
381+
/// Declares a tensor whose data starts at `tensor_data` inside `block`.
382+
virtual void bind(void* block, void* tensor_data) {
383+
(void)block;
384+
(void)tensor_data;
385+
}
386+
};
387+
388+
using IOBufferAllocatorFactory =
389+
std::function<std::unique_ptr<IOBufferAllocator>()>;
390+
391+
inline IOBufferAllocatorFactory& io_buffer_allocator_factory() {
392+
static IOBufferAllocatorFactory factory;
393+
return factory;
394+
}
395+
396+
/// Sets the allocator IOManagers use for their I/O buffers. The last call wins.
397+
inline bool set_io_buffer_allocator(IOBufferAllocatorFactory factory) {
398+
io_buffer_allocator_factory() = std::move(factory);
399+
return true;
400+
}
401+
246402
} // namespace llm
247403
} // namespace extension
248404
} // namespace executorch

‎extension/llm/runner/llm_runner_helper.cpp‎

Lines changed: 75 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
#include <executorch/extension/llm/runner/image_prefiller.h>
1212
#include <executorch/extension/llm/runner/llm_runner_helper.h>
13+
1314
#include <executorch/extension/llm/runner/multimodal_decoder_runner.h>
1415
#include <executorch/extension/llm/runner/multimodal_prefiller.h>
1516
#include <executorch/extension/llm/runner/multimodal_runner.h>
@@ -26,11 +27,15 @@
2627
#include <pytorch/tokenizers/tekken.h>
2728
#include <pytorch/tokenizers/tiktoken.h>
2829

30+
#include <limits>
31+
2932
namespace executorch::extension::llm {
3033

3134
using ::executorch::extension::Module;
3235
using ::executorch::runtime::Error;
3336

37+
void register_builtin_io_managers() {}
38+
3439
std::unique_ptr<tokenizers::Tokenizer> load_tokenizer(
3540
const std::string& tokenizer_path,
3641
std::unique_ptr<std::vector<std::string>> special_tokens,
@@ -126,6 +131,13 @@ get_llm_metadata(tokenizers::Tokenizer* tokenizer, Module* module) {
126131
if (!get_result.ok()) {
127132
return get_result.error();
128133
}
134+
if (!get_result->isInt() && !get_result->isBool()) {
135+
ET_LOG(
136+
Error,
137+
"Metadata method %s must return an integer or boolean.",
138+
method_name.c_str());
139+
return Error::InvalidArgument;
140+
}
129141
value = get_result->toScalar().to<decltype(metadata)::mapped_type>();
130142
} else {
131143
ET_LOG(
@@ -148,6 +160,15 @@ get_llm_metadata(tokenizers::Tokenizer* tokenizer, Module* module) {
148160
metadata[llm::kMaxContextLen]);
149161
}
150162

163+
for (const auto* name : {kMaxSeqLen, kMaxContextLen}) {
164+
const auto value = metadata.at(name);
165+
ET_CHECK_OR_RETURN_ERROR(
166+
value > 0 && value <= std::numeric_limits<int32_t>::max(),
167+
InvalidArgument,
168+
"Metadata %s must be in [1, INT32_MAX].",
169+
name);
170+
}
171+
151172
// Set tokenizer-related metadata
152173
metadata[llm::kBosId] = tokenizer->bos_tok();
153174
metadata[llm::kVocabSize] = tokenizer->vocab_size();
@@ -266,8 +287,32 @@ std::unique_ptr<TextLLMRunner> create_text_llm_runner(
266287
auto eos_ids = std::make_unique<std::unordered_set<uint64_t>>(
267288
llm::get_eos_ids(tokenizer.get(), module.get()));
268289

269-
// Create IOManager
270-
std::unique_ptr<IOManager> io_manager = std::make_unique<IOManager>(*module);
290+
// Create the IOManager for the I/O contract the program declares, if any.
291+
register_builtin_io_managers();
292+
std::string io_contract;
293+
if (auto names = module->method_names();
294+
names.ok() && names->count(kIoContract)) {
295+
auto contract = module->get(kIoContract);
296+
if (!contract.ok() || !contract->isString()) {
297+
ET_LOG(Error, "Failed to read the program's I/O contract label.");
298+
return nullptr;
299+
}
300+
io_contract = std::string(contract->toString());
301+
if (io_contract.empty()) {
302+
ET_LOG(Error, "The program's I/O contract label is empty.");
303+
return nullptr;
304+
}
305+
ET_LOG(Info, "I/O contract: %s", io_contract.c_str());
306+
}
307+
auto io_manager_result = make_io_manager(*module, io_contract);
308+
if (!io_manager_result.ok()) {
309+
ET_LOG(
310+
Error,
311+
"Failed to create the IOManager: 0x%x.",
312+
static_cast<unsigned>(io_manager_result.error()));
313+
return nullptr;
314+
}
315+
auto io_manager = std::move(*io_manager_result);
271316

272317
// Read vocab_size for Sampler
273318
int32_t vocab_size = static_cast<int32_t>(metadata.at(kVocabSize));
@@ -289,6 +334,17 @@ std::unique_ptr<TextLLMRunner> create_text_llm_runner(
289334
method_names.count(kPrefillMethod) > 0) {
290335
prefill_method_name = kPrefillMethod;
291336
}
337+
// An IOManager may drive methods of its own contract's names.
338+
auto [io_prefill, io_decode] = io_manager->method_names(method_names);
339+
if (!io_prefill.empty()) {
340+
prefill_method_name = io_prefill;
341+
}
342+
if (io_manager->load(
343+
prefill_method_name,
344+
io_decode.empty() ? prefill_method_name : io_decode) != Error::Ok) {
345+
ET_LOG(Error, "Failed to load the IOManager");
346+
return nullptr;
347+
}
292348

293349
// Create text_decoder_runner
294350
ET_LOG(Info, "Using method: %s", prefill_method_name.c_str());
@@ -302,7 +358,15 @@ std::unique_ptr<TextLLMRunner> create_text_llm_runner(
302358
// The decode stage gets its own decoder runner when the PTE exports a
303359
// separate decode method; otherwise both stages share text_decoder_runner.
304360
std::unique_ptr<TextDecoderRunner> decode_text_decoder_runner;
305-
if (method_names.count(kDecodeMethod) > 0 &&
361+
if (!io_decode.empty() && io_decode != prefill_method_name) {
362+
decode_text_decoder_runner = std::make_unique<TextDecoderRunner>(
363+
module.get(),
364+
io_manager.get(),
365+
io_decode,
366+
std::make_unique<Sampler>(vocab_size, init_temp),
367+
stats.get());
368+
} else if (
369+
io_decode.empty() && method_names.count(kDecodeMethod) > 0 &&
306370
prefill_method_name != kDecodeMethod) {
307371
ET_LOG(Info, "Using decode method: %s", kDecodeMethod);
308372
decode_text_decoder_runner = std::make_unique<TextDecoderRunner>(
@@ -316,12 +380,17 @@ std::unique_ptr<TextLLMRunner> create_text_llm_runner(
316380
? decode_text_decoder_runner.get()
317381
: text_decoder_runner.get();
318382

319-
// Create text_prefiller
383+
// Create text_prefiller. A prompt method with a fixed step size takes the
384+
// prompt in chunks of that size.
385+
const int64_t step_tokens =
386+
io_manager->max_tokens_per_step(prefill_method_name);
320387
auto text_prefiller = std::make_unique<TextPrefiller>(
321388
text_decoder_runner.get(),
322389
metadata.at(kUseKVCache),
323-
metadata.at(kEnableDynamicShape),
324-
metadata.at(kMaxSeqLen));
390+
step_tokens > 1 ? true
391+
: static_cast<bool>(metadata.at(kEnableDynamicShape)),
392+
step_tokens > 1 ? step_tokens : metadata.at(kMaxSeqLen),
393+
step_tokens > 1 ? decode_runner : nullptr);
325394

326395
// Create text_token_generator with stats
327396
auto text_token_generator = std::make_unique<TextTokenGenerator>(

‎extension/llm/runner/test/CMakeLists.txt‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ include(${EXECUTORCH_ROOT}/tools/cmake/Test.cmake)
1919

2020
set(_test_srcs
2121
test_generation_config.cpp
22+
test_io_manager.cpp
2223
test_model_metadata.cpp
2324
test_text_llm_runner.cpp
2425
test_text_prefiller.cpp
@@ -49,14 +50,21 @@ set(_metadata_pte_files
4950
"${CMAKE_CURRENT_BINARY_DIR}/ModelMetadata_geometry_invalid_windows.pte"
5051
"${CMAKE_CURRENT_BINARY_DIR}/ModelMetadata_geometry_empty.pte"
5152
)
53+
# Local builds may share a host with other model exports.
54+
set(_export_prefix)
55+
if(EXECUTORCH_TEST_EXPORT_LOCK)
56+
find_program(_flock flock REQUIRED)
57+
set(_export_prefix ${_flock} ${EXECUTORCH_TEST_EXPORT_LOCK})
58+
endif()
59+
5260
add_custom_command(
5361
OUTPUT ${_metadata_pte_files}
5462
# Run against the installed executorch (which stages exir/_serialize/*.fbs at
5563
# install time). Do NOT prepend the source tree to PYTHONPATH: it shadows the
5664
# installed package with a source copy that lacks the staged .fbs, breaking
5765
# to_executorch() under a non-editable install.
5866
COMMAND
59-
${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/export_model_metadata.py
67+
${_export_prefix} ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/export_model_metadata.py
6068
--outdir ${CMAKE_CURRENT_BINARY_DIR}
6169
WORKING_DIRECTORY ${EXECUTORCH_ROOT}
6270
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/export_model_metadata.py

‎extension/llm/runner/test/targets.bzl‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ def define_common_targets():
2727
],
2828
)
2929

30+
runtime.cxx_test(
31+
name = "test_io_manager",
32+
srcs = ["test_io_manager.cpp"],
33+
deps = [
34+
"//executorch/extension/llm/runner/io_manager:io_manager",
35+
],
36+
)
37+
3038
runtime.cxx_test(
3139
name = "test_text_prefiller",
3240
srcs = ["test_text_prefiller.cpp"],

0 commit comments

Comments
 (0)