Files
msime-windows/engine/neural/rescore_worker.cpp
T
fanlusky b8ceeef7f9 fix(engine): 重查时不再重复排正在打分的同一批候选
回调触发的重查会问到刚被后台取走、还没算完的那一批:结果表里没有,待办位也空着,
于是同一批又排了一份。用户连续打字时每个回调都可能白算一次;benchmark 里这份重复
任务会在 clear() 之后写回结果,让下一轮误报 neural_already_visible 或把等待计入
async_ms。

按模型记下正在算的键,同键请求直接返回并丢掉该模型更旧的待办。
2026-10-03 16:35:48 +08:00

197 lines
7.6 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "rescore_worker.h"
#include <algorithm>
#include <cstdint>
#include <cstdlib>
#include <utility>
namespace neural
{
namespace
{
// 结果表只是给「同一次输入被重查一遍」兜底的,不是长期缓存:装满就整张丢掉,比维护 LRU 简单,
// 代价也只是重算一次。
constexpr std::size_t kMaxResults = 128;
constexpr std::size_t kWorkerCount = 2;
std::string make_key(const SentenceModel &model, const std::string &context, const std::vector<std::string> &sentences)
{
// \x01 不会出现在 UTF-8 文本里,拿它当分隔符就不会把两批不同的候选拼成同一个键。
std::string key = std::to_string(reinterpret_cast<std::uintptr_t>(&model));
key.push_back('\x01');
key += context;
for (const std::string &sentence : sentences)
{
key.push_back('\x01');
key += sentence;
}
return key;
}
} // namespace
RescoreWorker &RescoreWorker::instance()
{
// 故意泄漏:后台线程只碰这个对象的成员,而静态对象的析构发生在退出时线程可能已被系统干掉之
// 后,join 会挂、detach 会悬空。永不析构把这道选择题去掉,进程退出时操作系统收走就是了。
static RescoreWorker *worker = new RescoreWorker();
return *worker;
}
void RescoreWorker::set_ready_callback(Ready ready)
{
std::lock_guard<std::mutex> lock(mutex_);
ready_ = std::move(ready);
}
void RescoreWorker::clear()
{
std::lock_guard<std::mutex> lock(mutex_);
done_.clear();
pending_.clear();
}
std::optional<std::vector<std::size_t>> RescoreWorker::order_for(const SentenceModel &model, const std::string &context,
const std::vector<std::string> &sentences,
const std::vector<double> &static_scores,
const RerankOptions &options)
{
std::string key = make_key(model, context, sentences);
{
std::lock_guard<std::mutex> lock(mutex_);
if (stopping_)
{
return std::nullopt;
}
const auto found = done_.find(key);
if (found != done_.end())
{
return found->second;
}
const auto active = active_keys_.find(&model);
if (active != active_keys_.end() && active->second == key)
{
// 正在算的就是这一批,算完自会进表并回调。这也是该模型最新的请求,排在它后面的旧待办
// 已经没人要了(用户打出去又退了回来),一并丢掉。
pending_.erase(&model);
return std::nullopt;
}
Job job;
job.model = &model;
job.key = std::move(key);
job.context = context;
job.sentences = sentences;
job.static_scores = static_scores;
job.options = options;
pending_[&model] = std::move(job);
ensure_threads();
}
wake_.notify_one();
return std::nullopt;
}
void RescoreWorker::shutdown()
{
std::unique_lock<std::mutex> lock(mutex_);
stopping_ = true;
ready_ = nullptr; // 目标很可能已经析构了,这之后一律不再回调
pending_.clear(); // 排着队还没开始的,直接不要了
wake_.notify_all();
idle_.wait(lock, [this] { return busy_workers_ == 0; });
}
void RescoreWorker::ensure_threads()
{
// 调用方持有 mutex_。两个执行槽让速度优先模型不必等效果优先模型打完分。
// 线程只在第一次真有活干时才起,没开神经重排的用户不会平白多线程。
if (threads_started_)
{
return;
}
threads_started_ = true;
for (std::size_t i = 0; i < kWorkerCount; ++i)
std::thread([this] { run(); }).detach();
std::atexit([] { instance().shutdown(); });
}
void RescoreWorker::run()
{
for (;;)
{
// 线程是 detach 出来的,异常逃出线程函数没人接,直接 std::terminate,一个只在后台改
// 候选顺序的功能会把整个输入法带走。所以整轮工作都包起来:坏的那一轮丢掉,线程接着
// 干下一轮。
//
// 收尾必须留在 try 外面,busy_model 是「这个执行槽被我占着」的凭据。放进 try 的话,一
// 次异常就再也降不下 busy_workers_,shutdown() 会卡在 idle_ 上等一个已经死掉的线程——
// 崩溃换成了退出时挂起,更难查。
const SentenceModel *busy_model = nullptr;
try
{
Job job;
{
std::unique_lock<std::mutex> lock(mutex_);
wake_.wait(lock, [this] {
return stopping_ || std::any_of(pending_.begin(), pending_.end(), [this](const auto &item) {
return active_keys_.find(item.first) == active_keys_.end();
});
});
if (stopping_)
{
return;
}
auto next = std::find_if(pending_.begin(), pending_.end(), [this](const auto &item) {
return active_keys_.find(item.first) == active_keys_.end();
});
job = std::move(next->second);
pending_.erase(next);
// emplace 是这三行里唯一可抛的(map 节点和键的副本要分配,会抛 bad_alloc),必须走
// 在凭据前面:它一抛,busy_model 还是空,整轮跳过收尾,busy_workers_ 压根没加过。反
// 过来先置凭据,一次分配失败就会让收尾去减一个没加过的计数,shutdown() 等不到归零。
active_keys_.emplace(job.model, job.key);
busy_model = job.model;
++busy_workers_;
}
// 打分在锁外做:它是这里最慢的一步,占着锁会让按键路径上的 order_for 跟着等。
std::vector<std::size_t> order =
rerank_order(*job.model, job.context, job.sentences, job.static_scores, job.options);
Ready ready;
{
std::lock_guard<std::mutex> lock(mutex_);
// 期间可能又来了新请求,说明用户还在打字,这一份存下来留给「他退回来」的情况。
if (done_.size() >= kMaxResults)
{
done_.clear();
}
done_.emplace(std::move(job.key), std::move(order));
ready = ready_; // shutdown 已经清过就是空的,那就不叫了
}
// 回调在锁外发:它会一路走到重查,重查又会调 order_for,在锁内发就是自锁。busy_ 要压
// 到回调之后才放,否则 shutdown 会在回调正往已析构的目标里走的时候就返回。
if (ready)
{
ready();
}
}
catch (...)
{
// 打分抛了就是没有顺序;回调抛了则顺序已经进表,只是没人被叫醒。两种情况下这一按键都
// 照词格的静态顺序出,调用方下一次按键会重新排。也刻意不叫回调:回调一路走到重查,重
// 查发现表里没有就再排一次,坏得稳定的模型会在这一对里空转。
}
if (busy_model != nullptr)
{
std::lock_guard<std::mutex> lock(mutex_);
active_keys_.erase(busy_model);
--busy_workers_;
}
wake_.notify_all();
idle_.notify_all();
}
}
} // namespace neural