量化开发面经:从原始代码逐版优化 SMA 行情处理
Table of contents
原始代码:
a.md场景:单生产者、单消费者处理多个合约的 Tick,并计算每个合约最近 100 笔价格的 SMA。 原则:每一版只解决一层问题。先保证能解释、能验证,再进入更复杂的并发优化。
0. 先看演进路线
这段代码不应该一步改成 lock-free、多 worker 的最终架构。面试中更合理的回答顺序是:
原始版本
每个 Tick:map 查找 + vector 头删 + 遍历窗口求和 + 单条队列同步
第一版:只改 SMA 计算
保留 map、mutex、condition_variable 和单生产者/单消费者
vector -> deque,完整求和 -> rolling sum
第二版:只改合约状态和内存布局
map -> vector,deque -> 固定长度环形数组
去掉节点查找、动态分配和指针跳转
第三版:只改线程间传输
无界逐 Tick 队列 -> 有界队列 -> 批量入队/出队
明确背压,同时减少锁和唤醒次数
第四版:再做多核扩展
按 instrument_id 稳定分片,每个合约只由一个 worker 更新
第五版:profiling 证明有必要后再做
SPSC ring、绑核、cache-line padding、NUMA、hot/cold split
下面每一版都先说明“相对上一版改了什么”,没有提到的部分保持不变。
1. 原始版本:先准确说出问题
原代码的并发正确性基础并不差:
wait(lock, predicate)能处理虚假唤醒;- 队列和
g_producer_finished由同一把 mutex 保护; - 消费者只有在“队列为空且生产者结束”时退出,因此不会遗漏队列里已有的数据;
- 生产者在释放锁后通知消费者,可以避免被唤醒线程立刻争抢同一把锁。
第一步不是把这些全部推翻,而是先找 consumer 热路径中确定存在的重复工作。
1.1 每个成熟 Tick 实际做了什么
原代码对一个已经填满 100 个价格的合约执行:
instrument_price.erase(instrument_price.begin()); // 搬移其余 99 个 double
instrument_price.push_back(current_tick.price);
instrument_sma.erase(instrument_sma.begin()); // 再搬移一遍结果
instrument_sma.push_back(calculate_sma(instrument_price));
// 再遍历 100 个价格
此外,每个 Tick 还通过两个 std::map::operator[] 分别寻找价格和结果:
instrument_sma_results[current_tick.instrument_id];
instrument_history_prices[current_tick.instrument_id];
设 Tick 数为 N、窗口大小为 W、合约数为 M,原热路径近似为:
O(N * (log M + W))
1.2 明确本题的历史数据语义
这里的需求是每个合约同时保留两段历史:
- 最近 100 个 price;
- 最近 100 个与 Tick 一一对应的 SMA 结果。
每收到一个有效 price,都必须产生一条 SMA 记录:
- 当前 price 历史不足 100 个时,SMA 记录为
NaN; - 收到第 100 个 price 时,第一次得到有效 SMA;
- 第 101 个 price 到来后,淘汰最旧 price,并计算新的 SMA;
- SMA 历史同样只保留最近 100 条,淘汰规则与 Tick 顺序对齐。
因此不能像上一稿那样只保存 latest_sma。下面每个版本都保留 100 个 price 和 100 个 SMA;checksum 只用于 benchmark 校验,不替代 SMA 历史。
2. 第一版:只改 SMA 计算,其他架构全部保留
2.1 相对原代码只改三件事
vector<double>改为deque<double>,使用pop_front()删除最旧价格;- 每个合约保存
sum,新价格加入时加上,旧价格离开时减掉; - SMA 结果也改成
deque,每个 Tick 写入一条,并只保留最近 100 条。
以下部分故意不改:
- 仍使用
std::map; - 仍使用全局
std::queue; - 仍然逐 Tick 加锁、出队;
- 仍然只有一个生产者和一个消费者。
这样能把“算法优化”和“并发架构优化”的收益分开验证。
2.2 第一版完整代码
#include <chrono>
#include <cmath>
#include <condition_variable>
#include <cstdint>
#include <deque>
#include <iostream>
#include <limits>
#include <map>
#include <mutex>
#include <queue>
#include <thread>
struct MarketDataTick {
long long timestamp;
int instrument_id;
double price;
int volume;
MarketDataTick(long long ts = 0, int id = 0,
double p = 0.0, int vol = 0)
: timestamp(ts), instrument_id(id), price(p), volume(vol) {}
};
std::queue<MarketDataTick> g_tick_queue;
std::mutex g_queue_mutex;
std::condition_variable g_queue_cv;
bool g_producer_finished = false;
inline constexpr std::size_t kSmaWindowSize = 100;
// 第一版的新状态:仍由 map 保存,但窗口使用 deque,并额外维护 rolling sum。
struct InstrumentSmaState {
std::deque<double> prices;
std::deque<double> sma_history;
double sum = 0.0;
};
void producer(int num_ticks, int num_instruments) {
for (int i = 0; i < num_ticks; ++i) {
MarketDataTick tick(
std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::high_resolution_clock::now().time_since_epoch()
).count(),
i % num_instruments,
100.0 + (i % 100) * 0.1 + (i % 7) * 0.05,
100 + (i % 50)
);
{
std::lock_guard<std::mutex> lock(g_queue_mutex);
g_tick_queue.push(tick);
}
g_queue_cv.notify_one();
}
{
std::lock_guard<std::mutex> lock(g_queue_mutex);
g_producer_finished = true;
}
g_queue_cv.notify_all();
}
void consumer() {
// 与原代码一样仍使用 map;第一版不碰这层结构。
std::map<int, InstrumentSmaState> states;
std::uint64_t processed_count = 0;
std::uint64_t sma_output_count = 0;
std::uint64_t nan_count = 0;
std::uint64_t ready_count = 0;
double checksum = 0.0;
while (true) {
MarketDataTick tick;
// 出队逻辑与原代码相同,只把手动 unlock 改成更清晰的 RAII 作用域。
{
std::unique_lock<std::mutex> lock(g_queue_mutex);
g_queue_cv.wait(lock, [] {
return !g_tick_queue.empty() || g_producer_finished;
});
if (g_tick_queue.empty() && g_producer_finished) {
break;
}
tick = g_tick_queue.front();
g_tick_queue.pop();
}
InstrumentSmaState& state = states[tick.instrument_id];
// 步骤 1:新价格进入窗口,同时加入 rolling sum。
state.prices.push_back(tick.price);
state.sum += tick.price;
// 步骤 2:窗口超过 100 时,减去并删除最旧价格。
if (state.prices.size() > kSmaWindowSize) {
state.sum -= state.prices.front();
state.prices.pop_front();
}
// 步骤 3:先把本 Tick 的结果初始化为 NaN。
// price 不足 100 个时,NaN 就是本 Tick 应保存和返回的结果。
double current_sma = std::numeric_limits<double>::quiet_NaN();
// 步骤 4:只有 price 窗口恰好填满后才计算有效 SMA。
if (state.prices.size() == kSmaWindowSize) {
current_sma = state.sum / static_cast<double>(kSmaWindowSize);
checksum += current_sma;
++ready_count;
} else {
++nan_count;
}
// 步骤 5:每个 Tick 都保存一条 SMA;历史最多保留最近 100 条。
state.sma_history.push_back(current_sma);
if (state.sma_history.size() > kSmaWindowSize) {
state.sma_history.pop_front();
}
++sma_output_count;
++processed_count;
}
std::size_t retained_price_count = 0;
std::size_t retained_sma_count = 0;
for (const auto& entry : states) {
retained_price_count += entry.second.prices.size();
retained_sma_count += entry.second.sma_history.size();
}
std::cout << "processed=" << processed_count
<< " sma_outputs=" << sma_output_count
<< " nan=" << nan_count
<< " ready=" << ready_count
<< " retained_price=" << retained_price_count
<< " retained_sma=" << retained_sma_count
<< " checksum=" << checksum << '\n';
}
int main() {
constexpr int kTickCount = 1'000'000;
constexpr int kInstrumentCount = 100;
static_assert(kInstrumentCount > 0);
std::thread producer_thread(producer, kTickCount, kInstrumentCount);
std::thread consumer_thread(consumer);
producer_thread.join();
consumer_thread.join();
}
2.3 第一版为什么已经有明显改进
成熟窗口每个 Tick 的计算从:
移动 99 个价格 + 移动 99 个 SMA + 遍历 100 个价格
变成:
price push/pop + sum 加新值/减旧值 + SMA history push/pop
窗口维护从 O(W) 降为摊还 O(1)。整个 consumer 仍包含 map 查询,因此总体近似为:
O(N log M)
2.4 第一版仍然没有解决什么
map仍然是树结构,每个 Tick 有O(log M)查找和指针跳转;deque内部仍可能分配分段存储;- 共享队列仍是无界的;
- 生产者和消费者仍逐 Tick 争用 mutex;
- 多消费者仍不能安全扩展;
doublerolling sum 仍会逐渐积累舍入误差;- 还没有封装“每次 update 必须返回一个数,未成熟时返回 NaN”的接口。
这些问题留到后续版本,不在第一版一次解决。
2.5 第一版怎么验证
用原始“每次遍历窗口求和”的实现作为 reference,对固定输入逐 Tick 比较:
const double error = std::abs(rolling_sma - reference_sma);
if (error > 1e-10) {
// 报告 instrument_id、tick 序号和两个结果。
}
100 个合约均匀接收 1,000,000 个 Tick 时,每个合约收到 10,000 个 Tick,前 99 个没有 SMA,因此成熟结果数应为:
100 * (10000 - 99) = 990100
同时还应满足:
SMA 输出总数 = 1,000,000
NaN 输出总数 = 100 * 99 = 9,900
最终保留的 SMA 历史数 = 100 个合约 * 每个 100 条 = 10,000
3. 第二版:只改合约状态和内存布局
第一版已经消除了最明显的 O(W) 重复工作。第二版再解决 map + deque 的查找、分配和 cache locality,但仍保留原来的全局 mutex 队列。
3.1 相对第一版改什么
- 合约 ID 已知是
[0, num_instruments),所以map改为vector; - price 和 SMA 历史都固定为 100,所以两个
deque都改为std::array<double, 100>; - 两段历史分别使用
next_指向下一次覆盖位置,形成真正的环形窗口; - 每隔一段时间完整重算窗口和,限制浮点累计误差。
队列、生产者、结束标志和单消费者仍保持第一版不变。
3.2 固定长度环形 SMA
#include <array>
#include <cstddef>
#include <limits>
#include <stdexcept>
// 通用的固定长度历史:写满后覆盖最旧元素,始终只保留最近 Capacity 条。
template <typename T, std::size_t Capacity>
class FixedHistory {
static_assert(Capacity > 0);
public:
void push(const T& value) {
values_[next_] = value;
next_ = (next_ + 1) % Capacity;
if (count_ < Capacity) {
++count_;
}
}
std::size_t size() const noexcept {
return count_;
}
// 按“从旧到新”的逻辑顺序读取,而不是按 array 的物理下标读取。
const T& oldest_at(std::size_t offset) const {
if (offset >= count_) {
throw std::out_of_range("history offset out of range");
}
const std::size_t oldest = count_ < Capacity ? 0 : next_;
return values_[(oldest + offset) % Capacity];
}
private:
std::array<T, Capacity> values_{};
std::size_t next_ = 0;
std::size_t count_ = 0;
};
template <std::size_t Window>
class RollingSma {
static_assert(Window > 0);
public:
double update(double price) {
if (count_ < Window) {
// 窗口未满:next_ 指向尚未使用的位置。
values_[next_] = price;
sum_ += price;
++count_;
} else {
// 窗口已满:values_[next_] 就是最旧值。
sum_ += price - values_[next_];
values_[next_] = price;
}
// 环形移动,不再删除或搬移其他元素。
next_ = (next_ + 1) % Window;
// rolling sum 会积累舍入误差,低频校准仍是摊还 O(1)。
if (--updates_until_rebase_ == 0) {
sum_ = 0.0;
for (std::size_t i = 0; i < count_; ++i) {
sum_ += values_[i];
}
updates_until_rebase_ = kRebaseInterval;
}
// 每个 price 都返回一个结果;不足 Window 时按需求返回 NaN。
if (count_ < Window) {
return std::numeric_limits<double>::quiet_NaN();
}
return sum_ * kInverseWindow;
}
std::size_t size() const noexcept {
return count_;
}
private:
static constexpr std::size_t kRebaseInterval = 4096;
static constexpr double kInverseWindow =
1.0 / static_cast<double>(Window);
std::array<double, Window> values_{};
std::size_t next_ = 0;
std::size_t count_ = 0;
std::size_t updates_until_rebase_ = kRebaseInterval;
double sum_ = 0.0;
};
template <std::size_t Window>
class InstrumentState {
public:
double on_price(double price) {
const double sma = price_window_.update(price);
sma_history_.push(sma); // NaN 和有效 SMA 都进入最近 100 条历史。
return sma;
}
double on_invalid_price() {
// 非法 price 不污染 price window,但当前 Tick 仍留下一个 NaN 结果。
const double nan = std::numeric_limits<double>::quiet_NaN();
sma_history_.push(nan);
return nan;
}
const FixedHistory<double, Window>& sma_history() const noexcept {
return sma_history_;
}
std::size_t price_history_size() const noexcept {
return price_window_.size();
}
private:
RollingSma<Window> price_window_; // 最近 100 个有效 price。
FixedHistory<double, Window> sma_history_; // 最近 100 个对应输出。
};
3.3 consumer 只替换状态部分
原来的出队代码完全不动,只将 consumer 中的状态定义和 Tick 处理替换为:
void consumer(std::size_t instrument_count) {
// ID 稠密时,states[id] 比 map 查找更直接且内存连续。
std::vector<InstrumentState<100>> states(instrument_count);
std::uint64_t processed_count = 0;
std::uint64_t sma_output_count = 0;
std::uint64_t nan_count = 0;
std::uint64_t ready_count = 0;
double checksum = 0.0;
while (true) {
MarketDataTick tick;
{
std::unique_lock<std::mutex> lock(g_queue_mutex);
g_queue_cv.wait(lock, [] {
return !g_tick_queue.empty() || g_producer_finished;
});
if (g_tick_queue.empty() && g_producer_finished) {
break;
}
tick = g_tick_queue.front();
g_tick_queue.pop();
}
if (tick.instrument_id < 0 ||
static_cast<std::size_t>(tick.instrument_id) >= states.size()) {
continue;
}
auto& state = states[static_cast<std::size_t>(tick.instrument_id)];
const double sma = std::isfinite(tick.price)
? state.on_price(tick.price)
: state.on_invalid_price();
// NaN 是合法的 warm-up 输出,但不进入数值 checksum。
if (!std::isnan(sma)) {
checksum += sma;
++ready_count;
} else {
++nan_count;
}
++sma_output_count;
++processed_count;
}
}
3.4 为什么不总是用 vector
这里能够使用 vector,是因为模拟数据明确生成连续 ID:
i % num_instruments
真实市场的 instrument ID 如果非常稀疏,直接按最大 ID 创建 vector 会浪费大量空间。此时第二版应改为:
std::unordered_map<int, InstrumentState<100>> states;
states.reserve(expected_instrument_count);
只有需要按合约 ID 有序遍历时才保留 std::map。
3.5 第二版达到的复杂度
每个 Tick:
- 按下标定位状态:
O(1); - 覆盖环形窗口:
O(1); - 更新滚动和:
O(1); - 周期校准:摊还
O(1)。
计算部分整体降为 O(N)。这时 profiling 很可能开始显示共享队列同步占比上升,才进入第三版。
4. 第三版:只改队列,不改变 SMA 算法
第二版的计算已经很轻,逐 Tick mutex、条件变量通知和无界 std::queue 可能成为主要问题。第三版分两小步,先保证过载正确,再优化吞吐。
4.1 第三版 A:先把队列变成有界队列
原队列无上限。如果生产者持续快于消费者,内存会一直增长。增加容量后,队列满时必须定义行为:
| 策略 | 优点 | 风险 |
|---|---|---|
| 阻塞生产者 | 不丢 Tick | 可能阻塞行情回调线程 |
| 丢最新 Tick | 保住已有队列 | 数据越来越旧 |
| 丢最旧 Tick | 保留最新行情 | 破坏逐笔 SMA 语义 |
| 同合约只保留最新值 | 适合快照 | 不适合逐笔成交 |
| 写日志后回放 | 可靠 | 延迟和工程成本更高 |
示例先选择“阻塞生产者”,因为它最容易保持与原代码相同的结果语义。实际系统必须根据行情类型决定。
4.2 第三版 B:在有界基础上批量传输
#include <algorithm>
#include <condition_variable>
#include <cstddef>
#include <deque>
#include <iterator>
#include <mutex>
#include <stdexcept>
#include <vector>
template <typename T>
class BoundedBatchQueue {
public:
explicit BoundedBatchQueue(std::size_t capacity)
: capacity_(capacity) {
if (capacity_ == 0) {
throw std::invalid_argument("capacity must be positive");
}
}
bool push_batch(std::vector<T>& batch) {
if (batch.empty()) {
return true;
}
if (batch.size() > capacity_) {
throw std::invalid_argument("batch exceeds queue capacity");
}
std::unique_lock<std::mutex> lock(mutex_);
not_full_.wait(lock, [&] {
return closed_ || queue_.size() + batch.size() <= capacity_;
});
if (closed_) {
return false;
}
const bool was_empty = queue_.empty();
queue_.insert(queue_.end(),
std::make_move_iterator(batch.begin()),
std::make_move_iterator(batch.end()));
batch.clear();
lock.unlock();
// 只有从空变为非空时,等待中的消费者才一定需要被唤醒。
if (was_empty) {
not_empty_.notify_one();
}
return true;
}
std::size_t pop_batch(std::vector<T>& output,
std::size_t max_batch_size) {
std::unique_lock<std::mutex> lock(mutex_);
not_empty_.wait(lock, [&] {
return closed_ || !queue_.empty();
});
if (queue_.empty()) {
output.clear();
return 0; // closed 且已经排空。
}
const std::size_t count =
std::min(max_batch_size, queue_.size());
output.clear();
for (std::size_t i = 0; i < count; ++i) {
output.push_back(std::move(queue_.front()));
queue_.pop_front();
}
lock.unlock();
not_full_.notify_one();
return count;
}
void close() {
{
std::lock_guard<std::mutex> lock(mutex_);
closed_ = true;
}
not_empty_.notify_all();
not_full_.notify_all();
}
private:
const std::size_t capacity_;
std::deque<T> queue_;
bool closed_ = false;
std::mutex mutex_;
std::condition_variable not_empty_;
std::condition_variable not_full_;
};
4.3 生产者如何逐步接入 batch
constexpr std::size_t kBatchSize = 256;
std::vector<MarketDataTick> batch;
batch.reserve(kBatchSize);
for (std::size_t i = 0; i < tick_count; ++i) {
batch.push_back(make_tick(i));
if (batch.size() == kBatchSize) {
queue.push_batch(batch); // 一批只获取一次锁。
}
}
queue.push_batch(batch); // 提交最后不足 256 条的数据。
queue.close();
真实行情不能一直等到凑够 256 条,应使用双阈值:
batch.size() 达到上限
或
距离上次提交超过 latency_budget
满足任一条件就提交,避免低流量时 Tick 长时间滞留在生产者本地。
4.4 消费者如何在锁外计算
std::vector<MarketDataTick> batch;
batch.reserve(kBatchSize);
while (queue.pop_batch(batch, kBatchSize) != 0) {
// pop_batch 返回时已经释放队列锁。
for (const MarketDataTick& tick : batch) {
const double sma = states[tick.instrument_id].on_price(tick.price);
// state 内部已经把本次结果写入最近 100 条 SMA 历史。
// warm-up 阶段的 NaN 不参与 checksum,但仍是正式输出。
if (!std::isnan(sma)) {
checksum += sma;
}
}
}
锁的次数大致从 N 降到 N / batch_size。但 batch 越大不一定越好:吞吐通常提高,单 Tick 等待时间和 P99 延迟也可能增加。
4.5 第三版必须新增的测试
- 队列容量为 1;
- 生产者比消费者快,确认不会无限增长;
- 消费者等待时关闭队列,确认能够退出;
- 队列关闭前已有数据,确认先排空再退出;
- 最后一批不足 batch size,确认没有遗漏;
- 优化前后 ready count 和逐 Tick SMA 完全对应;
- 记录最大 queue depth、生产者阻塞时间和 P99 延迟。
5. 第四版:按合约分片到多个 worker
只有单消费者 CPU 已经成为瓶颈时,才进入第四版。
5.1 为什么不能让多个消费者直接抢同一个队列
SMA 是每个合约的有序状态机。同一合约的第 t 个 Tick 必须在第 t-1 个 Tick 之后更新同一窗口。多个消费者自由竞争会产生三类问题:
- 多线程同时修改同一状态,产生 data race;
- 即使给状态加锁,获得锁的先后也可能打乱 Tick 顺序;
- 每个 worker 如果维护独立 states,同一合约历史会被拆散。
5.2 正确方案:按 instrument_id 稳定路由
std::size_t shard_for(int instrument_id, std::size_t worker_count) {
return std::hash<int>{}(instrument_id) % worker_count;
}
void dispatch(const MarketDataTick& tick) {
const std::size_t worker = shard_for(tick.instrument_id, workers.size());
workers[worker].queue.push(tick);
}
架构变成:
行情接入/dispatcher
-> instrument hash
-> worker 0 queue -> worker 0 独占 states
-> worker 1 queue -> worker 1 独占 states
-> worker 2 queue -> worker 2 独占 states
-> worker 3 queue -> worker 3 独占 states
同一合约永远进入同一 worker,因此:
- 合约内顺序可以保留;
- SMA 状态只有一个写线程,不需要加锁;
- 不同合约可以并行;
- 每个 worker 可以独立输出 batch,减少共享结果锁。
5.3 第四版还要考虑负载倾斜
简单 hash 只能平衡合约数量,不能保证 Tick 数均衡。一个极活跃合约仍可能让某个 worker 过载。可选方法:
- 根据历史 Tick 速率做 weighted assignment;
- 定期在安全点迁移整个合约状态;
- 对极热合约单独分配 worker;
- 监控每个 shard 的吞吐、queue depth 和 P99。
不能把同一合约的单个 SMA 状态随意拆给多个线程,因为状态转移本身有严格顺序。
6. 第五版:profiling 后才考虑低延迟专项优化
前四版完成后,再用 perf 或火焰图确认剩余瓶颈。如果同步和 cache miss 仍占主要成本,才评估下列优化。
6.1 每个 shard 使用 SPSC ring
dispatcher 是一个生产者,每个 worker 是自己队列的唯一消费者,满足 SPSC 条件。固定容量 ring 可以避免 mutex 和队列节点分配。
核心思路是:
producer 只更新 tail
consumer 只更新 head
producer 发布新元素时使用 release
consumer 读取 tail 时使用 acquire
但必须同时设计:
- 队列满时的背压/丢弃语义;
- 关闭协议;
- head 与 tail 的 cache-line padding;
- 忙等、yield、睡眠或
atomic_wait的切换策略; - 元素构造、析构与异常安全。
因此不建议面试现场把“自己手写 lock-free”说成第一选择,可以说优先使用经过验证的队列实现。
6.2 Tick 结构体布局
原结构顺序在常见 ABI 上可能产生填充:
struct MarketDataTick {
long long timestamp;
int instrument_id;
double price;
int volume;
};
可以把 8 字节字段放在一起:
struct MarketDataTick {
std::int64_t timestamp;
double price;
std::int32_t instrument_id;
std::int32_t volume;
};
常见平台上可能从 32 字节缩小到 24 字节,但这不是 C++ 标准保证,必须用本机 sizeof 和 benchmark 验证。
若 SMA 热路径只读取 ID 和价格,可以进一步把 timestamp、volume 放入冷数据,或批量使用 SoA:
instrument_ids[]
prices[]
timestamps[]
volumes[]
6.3 绑核、NUMA 和 false sharing
- dispatcher 与 worker 可绑定独立 CPU,降低迁核和 cache 抖动;
- 队列内存和合约状态在消费它的 NUMA node 上分配;
- 不同 worker 的高频计数器分开 cache line,避免 false sharing;
- 线程数不要超过实际可用核心,并避免与 MKL/OpenMP 内部线程过度订阅。
这些优化高度依赖硬件,必须在确定的部署环境中测试,不能只凭代码推断收益。
7. 每一版的变化汇总
| 版本 | 只解决什么 | 保留什么 | 计算复杂度 |
|---|---|---|---|
| 原始版 | 基本生产消费链路 | map + vector + 完整求和 |
O(N(log M+W)) |
| 第一版 | price/SMA 头删和重复求和 | map + 两段 100 条历史 + 原队列 |
O(N log M) |
| 第二版 | map/deque 查找与分配 | 两个固定环形历史、原 mutex 队列 | O(N) |
| 第三版 | 无界队列和逐条同步 | 单生产者、单消费者 | O(N),同步按 batch 摊薄 |
| 第四版 | 单消费者 CPU 瓶颈 | 每个合约单写、有序 | 多 shard 并行 |
| 第五版 | 剩余同步/cache/NUMA | 前面已经验证的语义 | 依硬件与实现而定 |
8. 逐版 benchmark,而不是只报最后一个数字
原代码末尾的 5.15096e+06 ticks/sec 只是一次运行,不能直接作为可靠结论,因为它混合了:
- 每 Tick 一次
high_resolution_clock::now(); - 生成数据的取模运算;
- 线程启动和调度;
- 队列传输与 SMA 计算;
- 未说明的编译选项、CPU 和系统负载。
建议为每一版填写同一张表:
| 版本 | kernel ticks/s | end-to-end ticks/s | P50 | P99 | max queue depth | checksum |
|---|---|---|---|---|---|---|
| 原始版 | 待测 | 待测 | 待测 | 待测 | 无界 | 待测 |
| 第一版 | 待测 | 待测 | 待测 | 待测 | 无界 | 必须一致 |
| 第二版 | 待测 | 待测 | 待测 | 待测 | 无界 | 必须一致 |
| 第三版 | 待测 | 待测 | 待测 | 待测 | 记录 | 必须一致 |
| 第四版 | 待测 | 待测 | 待测 | 待测 | 分 shard 记录 | 必须一致 |
性能指标之外,每一版还必须满足同一组功能指标:
每个有效 Tick 恰好产生一条 SMA 输出
每个合约 price 不足 100 条时输出 NaN
第 100 条 price 开始输出有限 SMA
每个合约最终只保留最近 100 个 price 和最近 100 个 SMA
按逻辑时间顺序遍历环形历史时,两段数据保持对齐
8.1 基准测试规则
- Release 编译:
-O3 -DNDEBUG -march=native -pthread; - 使用固定输入回放,每一版处理完全相同的数据;
steady_clock测耗时,交易所时间戳使用行情源字段;- 先 warm-up,再多次运行,报告中位数和波动范围;
- 分开测试纯 SMA kernel、queue 和端到端;
- 对每个合约逐点比较:warm-up 位置必须同为 NaN,成熟窗口必须在容差内一致;
- 同时报吞吐和 P99,batch 提升吞吐不代表延迟更低;
- 用 checksum 防止无观察者计算,但 checksum 不能替代逐点对拍;
- 使用
perf stat、火焰图、ThreadSanitizer 和 UBSan 验证判断。
9. 贯穿所有版本的正确性问题
9.1 最近 100 笔不是最近 10 秒
原代码是 tick-count window。活跃合约 100 笔可能只覆盖几毫秒,冷门合约可能覆盖几分钟。如果需求是时间窗口,就要为每个合约保存 (event_timestamp, price),并按事件时间淘汰。
此时还要定义:
- event time 还是 arrival time;
- 允许多大乱序;
- watermark 如何推进;
- 迟到数据丢弃、修正还是重算。
9.2 rolling sum 的数值误差
double 无法精确表示大多数十进制价格,长期执行 sum += new-old 会累积误差。可选方案:
- 定期完整重算窗口和;
- 用
long double保存 sum; - 有固定 tick size 时把价格转成整数 tick;
- 账务金额使用定点/decimal,不直接照搬指标计算的
double。
9.3 行情质量和过载
至少要监控或处理:
- sequence gap、重复包和乱序包;
- 非法 ID、NaN、Inf、异常成交量;
- 队列丢弃数量、最大深度和数据新鲜度;
- 交易暂停、复权、期货换月和合约生命周期;
- 生产者或消费者异常退出后的关闭和恢复。
10. 面试怎么回答
10.1 第一层:先说第一版
我不会一上来就把它改成 lock-free。先看原 consumer,每个成熟 Tick 都会对 price 和 SMA 历史执行
vector.erase(begin()),还要重新遍历 100 个 price 求和。第一版保留原来的 map、mutex、condition_variable 和单生产者单消费者,只把两段历史改成 deque,并为每个合约维护 rolling sum。每个 Tick 都向 SMA 历史写一条结果:price 不足 100 个就写 NaN,第 100 个开始写实际 SMA;两段历史都只保留最近 100 条。这样既保留原需求,又把窗口维护从 O(W) 降成 O(1)。
10.2 对方继续问,再说第二版
第一版之后还有每 Tick 的 map 查找和 deque 分段存储。模拟代码的 instrument_id 是连续的,所以第二版把合约状态改成 vector,下标直接定位;price window 和 SMA history 都固定为 100,就分别用 array 做环形缓冲区。SMA update 直接返回 double,warm-up 返回 NaN,并把这个返回值写入第二个环。这样既保持两段 100 条历史,也去掉 map 查找、节点分配和窗口动态分配,计算部分整体做到 O(N)。
10.3 对方继续问并发,再说第三、四版
计算优化后再 profile 队列。如果逐 Tick 锁和唤醒成为瓶颈,第三版先改成有界队列明确背压,再按数量或时间阈值批量传输,消费者把一批 Tick 移到本地后在锁外计算。需要多核时不能让多个消费者随便抢同一个队列,因为 SMA 有合约内顺序;第四版要按 instrument_id 稳定分片,让同一个合约始终由同一个 worker 更新。只有这些做完后同步仍是热点,我才会考虑每个 shard 使用 SPSC ring、绑核和 NUMA 优化。
11. 常见追问
Q1:第一版为什么用 deque,不直接用 array?
因为第一版只想验证最明显的算法改动,并尽量少改原代码。deque 可以直接替换 vector 的头删,代码改动很小。固定 array 和环形下标属于第二版的数据布局优化,单独测量更容易说明收益来源。
Q2:为什么第一版不删掉 map?
同样是为了单变量实验。第一版只证明 rolling sum 消除了 O(W);第二版再证明连续状态消除了 O(log M) 和 pointer chasing。如果一起改,benchmark 只能看到总收益,解释不出每个改动的贡献。
Q3:rolling sum 会不会算不准?
会积累浮点舍入误差,所以第二版加入低频完整重算进行校准。若价格有固定最小变动单位,还可以转成整数 tick 累加。
Q4:为什么不能直接开四个 consumer?
因为同一合约的 SMA 必须按 Tick 顺序更新一个共享状态。直接抢队列可能产生数据竞争、乱序或把历史拆散。应按 instrument_id 分片,让一个合约只有一个写 worker。
Q5:batch 越大越好吗?
不是。batch 越大,锁开销摊得越薄,但 Tick 等待凑批的时间越长。应设置数量和时间双阈值,并同时看吞吐和 P99。
Q6:为什么最后才考虑 lock-free?
因为它只优化传输同步,不会解决原来的 O(W) 计算和 map 查找,而且内存序、关闭、满队列和忙等策略更容易写错。先完成低风险优化,再用 profiling 判断同步是否值得复杂化。
Q7:为什么 warm-up 返回 NaN,不用 optional?
这里需要让 price 序列和 SMA 序列按 Tick 一一对齐,所以每个输入都必须占据一个输出位置。NaN 能明确表示“这个位置存在,但窗口尚未成熟”;使用 optional 也能表达未就绪,却会让调用方额外决定是否向历史写占位值。既然接口要求固定长度数值历史,就直接返回并保存 NaN,但后续聚合、比较和 checksum 必须显式用 std::isnan() 排除它。
12. 最终检查清单
- 第一版使用两个 deque 保留最近 100 个 price 和 SMA,并用 rolling sum;
- 每个有效 Tick 都产生 SMA 记录,前 99 个为 NaN,第 100 个开始有效;
- 第二版再把两段历史改为 fixed ring,并按逻辑时间顺序验证对齐;
- 第三版先定义背压,再增加 batch;
- 第四版按 instrument_id 分片,验证合约内 sequence 单调;
- 第五版只在 profiling 支持时进入;
- 明确是 Tick 数窗口还是时间窗口;
- 处理 NaN、Inf、非法 ID、重复、丢包和乱序;
- rolling sum 定期校准或使用定点价格;
- 每版 SMA 输出数、NaN 数、成熟结果数、保留历史数和 checksum 一致;
- 同时记录吞吐、P99、queue depth、CPU 和丢弃数;
- 使用固定回放、Release 编译和多轮统计;
- 使用 sanitizer 与 perf 验证正确性和瓶颈。