Skip to content

Commit 04063c0

Browse files
fix(llm): snapshot vision encoder output before caching (#1229)
## Description In any multimodal conversation with more than one image, the model starts describing earlier images as the most recently sent one on later turns. `VisionEncoder::encode` caches the `EValue` returned by `vision_encoder.execute()` per image path. That tensor aliases the method's reusable output buffer, so the next `execute()` (the second image, or any later encode) overwrites the bytes behind every cached entry. On re-prefilled turns the prefiller then splices the latest image's embeddings into every image slot. The audio path already snapshots its encoder output for exactly this reason (see the `AudioSlot` comment in `multimodal_prefiller.cpp`); vision never got the same treatment. The fix copies the encoder output into bytes owned by the cache entry immediately after `execute()` and serves cache hits from a tensor wrapping those owned bytes (`unordered_map` nodes are pointer-stable, so the blob stays valid). The bug is backend-independent (the cache sits above the delegate), so XNNPACK/Vulkan multimodal models are affected the same way. ### Introduces a breaking change? - [ ] Yes - [x] No ### Type of change - [x] Bug fix (change which fixes an issue) - [ ] New feature (change which adds functionality) - [ ] Documentation update (improves or adds clarity to existing documentation) - [ ] Other (chores, tests, code style improvements etc.) ### Tested on - [x] iOS - [ ] Android ### Testing instructions 1. Run the example LLM app with a multimodal model (e.g. Gemma 4 E2B multimodal) on the Multimodal LLM screen. 2. Send image A with "What's in this picture?" — answer is correct. 3. Send image B (different content) with the same question — answer is correct. 4. Ask "What was in the FIRST picture I sent?". Before this fix, step 4 describes image B's content (both image slots receive B's embeddings on the re-prefilled turn). After the fix, the model correctly recalls image A. ### Screenshots N/A ### Related issues N/A ### Checklist - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have updated the documentation accordingly - [x] My changes generate no new warnings ### Additional notes Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 140bf84 commit 04063c0

2 files changed

Lines changed: 28 additions & 6 deletions

File tree

‎packages/react-native-executorch/common/runner/encoders/vision_encoder.cpp‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ Result<EValue> VisionEncoder::encode(const MultimodalInput &input) {
112112

113113
auto it = embedding_cache_.find(path);
114114
if (it != embedding_cache_.end()) {
115-
return it->second;
115+
return EValue(*it->second.tensor);
116116
}
117117

118118
auto shape = ET_UNWRAP(getInputShape());
@@ -128,9 +128,18 @@ Result<EValue> VisionEncoder::encode(const MultimodalInput &input) {
128128
chw.data(), sizes, ::executorch::aten::ScalarType::Float);
129129

130130
auto result = ET_UNWRAP(module_->execute(kVisionEncoderMethod, image_tensor));
131-
auto embedding = result[0];
132-
embedding_cache_.emplace(path, embedding);
133-
return embedding;
131+
auto out_tensor = result[0].toTensor();
132+
133+
CachedEmbedding cached;
134+
cached.bytes.resize(out_tensor.nbytes());
135+
std::memcpy(cached.bytes.data(), out_tensor.const_data_ptr(),
136+
out_tensor.nbytes());
137+
cached.sizes.assign(out_tensor.sizes().begin(), out_tensor.sizes().end());
138+
cached.dtype = out_tensor.scalar_type();
139+
auto [entry, inserted] = embedding_cache_.emplace(path, std::move(cached));
140+
entry->second.tensor = ::executorch::extension::from_blob(
141+
entry->second.bytes.data(), entry->second.sizes, entry->second.dtype);
142+
return EValue(*entry->second.tensor);
134143
}
135144

136145
} // namespace executorch::extension::llm

‎packages/react-native-executorch/common/runner/encoders/vision_encoder.h‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22
#pragma once
33

44
#include "iencoder.h"
5+
#include <cstdint>
56
#include <executorch/extension/module/module.h>
7+
#include <executorch/extension/tensor/tensor.h>
68
#include <executorch/runtime/core/evalue.h>
79
#include <runner/multimodal_input.h>
810
#include <string>
911
#include <unordered_map>
12+
#include <vector>
1013

1114
namespace executorch::extension::llm {
1215

@@ -26,13 +29,23 @@ class VisionEncoder : public IEncoder {
2629
bool with_batch;
2730
};
2831

32+
// The method's output EValue aliases the runtime's reusable output buffer,
33+
// which the NEXT vision_encoder.execute() overwrites — caching it directly
34+
// silently turns earlier images into the most recently encoded one. Cache
35+
// an owned byte snapshot instead and hand out a tensor over those bytes.
36+
struct CachedEmbedding {
37+
std::vector<uint8_t> bytes;
38+
std::vector<::executorch::aten::SizesType> sizes;
39+
::executorch::aten::ScalarType dtype;
40+
::executorch::extension::TensorPtr tensor;
41+
};
42+
2943
::executorch::runtime::Result<ImageShape> getInputShape() const;
3044
std::vector<float> preprocessImage(const std::string &path,
3145
const ImageShape &targetShape) const;
3246

3347
::executorch::extension::Module *module_;
34-
std::unordered_map<std::string, ::executorch::runtime::EValue>
35-
embedding_cache_;
48+
std::unordered_map<std::string, CachedEmbedding> embedding_cache_;
3649
};
3750

3851
} // namespace executorch::extension::llm

0 commit comments

Comments
 (0)