Files
Radio/doc/ai.md
T
2026-07-22 18:08:10 +08:00

688 lines
12 KiB
Markdown
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.
下面这个就是我建议你这个 **多图表后台渲染库**的完整模型。
核心目标是:
```text
元数据变化才计算
后台只算最新状态
主线程只贴图
多个图统一调度
不堆旧任务
不显示半成品
不因为 paintEvent 无意义重算
```
---
# 1. 总体管线
每个图都是这个流程:
```text
主线程修改元数据
meta_version++
调度器定时扫描
判断这个图是否需要提交 CPU 任务
生成元数据快照 Render_Input
扔进 CPU 线程池
CPU 线程生成 Pixel_Frame
发布到三缓冲 ready
主线程 update 合并
paintEvent 消费 ready,贴 front
```
也就是:
```text
Meta -> Render_Input -> CPU Render -> Pixel_Frame -> paintEvent drawImage
```
每种具体图只需要实现:
```text
元数据 -> 像素图
```
线程控制、版本控制、三缓冲、刷新合并都放到通用框架里。
---
# 2. 每个图不要自己开线程
一堆图不能每个图一个线程、一个 timer。
应该是:
```text
一个 Render_Scheduler
一个 CPU Thread_Pool
多个 Chart_Node
```
结构类似:
```text
Render_Scheduler
Chart_Node 1
Chart_Node 2
Chart_Node 3
...
Thread_Pool
```
调度器负责:
```text
扫描哪些图需要重算
限制每轮提交数量
限制单图提交频率
跳过不可见图
合并 update 区域
控制 CPU 线程池压力
```
具体图只负责:
```text
准备元数据
执行 CPU 绘制算法
提供 dirty rect / 绘制区域
```
---
# 3. 每个图的版本号
每个图建议维护这些版本:
```cpp
struct Chart_Versions {
std::uint64_t meta = 0;
std::uint64_t submitted = 0;
std::uint64_t running = 0;
std::uint64_t rendered = 0;
std::uint64_t requested_paint = 0;
std::uint64_t painted = 0;
};
```
语义:
```text
meta
主线程最新元数据版本。
submitted
已经提交给 CPU 线程池的版本。
running
当前 CPU 正在计算的版本。
rendered
后台已经生成像素图的版本。
requested_paint
已经请求主线程刷新的版本。
painted
paintEvent 已经贴出去的版本。
```
这里不要用 `OK``imageReady` 这种 bool。
bool 只能表示:
```text
有 / 没有
```
版本号能表示:
```text
是哪一版
新不新
有没有提交
有没有生成
有没有请求 update
有没有真正 paint
```
---
# 4. 元数据层:双缓冲或者快照
元数据是用户操作和数据输入修改的东西,比如:
```text
坐标范围
颜色表
缩放
图尺寸
数据源
采样参数
显示开关
曲线样式
```
主线程修改元数据:
```cpp
void Chart_Node::mark_meta_changed() {
++versions.meta;
}
```
调度器提交任务时生成快照:
```cpp
Render_Input Chart_Node::make_render_input() {
Render_Input input;
input.meta = edit_meta;
input.version = versions.meta;
return input;
}
```
如果元数据很小,直接拷贝快照。
如果元数据很大,用双缓冲或者共享不可变快照。
这一层一般不需要三缓冲。
原因是元数据中间版本可以丢,只要后台下一轮拿最新版本即可。
---
# 5. 任务层:single-flight
每个图同一时间最多一个后台任务。
不要这样:
```text
version 100 提交
version 101 提交
version 102 提交
version 103 提交
```
这样会造成旧任务堵新任务。
应该这样:
```text
一个图最多一个任务在飞
任务运行期间 meta 变了,只记录版本变化
任务完成后调度器下一轮再提交最新版本
```
任务状态:
```cpp
enum class Render_Job_State {
Idle,
Queued,
Running
};
```
提交条件:
```cpp
bool Chart_Node::need_submit(std::uint64_t now_ms) const {
if (!visible) {
return false;
}
if (!valid_size) {
return false;
}
if (job_state != Render_Job_State::Idle) {
return false;
}
if (versions.meta == versions.submitted) {
return false;
}
if (now_ms - last_submit_time < min_render_interval) {
return false;
}
return true;
}
```
核心原则:
```text
版本没变,不算。
图不可见,不算。
尺寸无效,不算。
已经有任务在跑,不算。
刚算过,间隔太短,不算。
```
---
# 6. 像素结果层:三缓冲
像素层建议三缓冲:
```cpp
struct Pixel_Frame {
std::uint64_t version = 0;
QImage image;
};
struct Pixel_Buffers {
Pixel_Frame front;
Pixel_Frame ready;
Pixel_Frame working;
};
```
三张图语义:
```text
front
主线程 paintEvent 当前稳定显示的图。
ready
后台已经生成好,等待主线程消费的图。
working
后台线程正在写入的图。
```
三缓冲解决的是:
```text
主线程读 front
后台写 working
ready 作为交接区
```
它不是为了替代版本号,而是为了控制图像内存所有权。
---
# 7. 后台任务完成后的发布规则
后台线程生成完 `working` 后,不直接碰 `front`
只发布到 `ready`
如果 `ready` 还没被主线程消费,新 `working` 又生成完了:
```text
旧 ready 丢掉
新 working 覆盖 ready
front 继续稳定显示
```
图表库不需要按帧播放旧图,应该追最新状态。
发布逻辑:
```cpp
void Chart_Node::publish_working_frame(Pixel_Frame frame) {
bool need_update = false;
{
std::scoped_lock lock(pixel_lock);
if (frame.version <= pixels.ready.version) {
return;
}
pixels.working = std::move(frame);
pixels.ready.swap(pixels.working);
versions.rendered = pixels.ready.version;
if (versions.rendered != versions.requested_paint) {
versions.requested_paint = versions.rendered;
need_update = true;
}
job_state = Render_Job_State::Idle;
}
if (need_update) {
scheduler->request_update(this);
}
}
```
状态变化示例:
```text
发布前:
front = 100
ready = 101
working = 102
发布后:
front = 100
ready = 102
working = 101
```
`ready = 101` 被丢掉,变成新的空闲 working 槽,后面会被覆盖。
---
# 8. 已经开始生成的 working 要不要停
不要停。
规则是:
```text
已经开始生成的 working:继续生成完。
还没开始的新任务:由调度器根据版本、间隔、状态决定是否提交。
```
原因:
```text
CPU 任务中途取消复杂。
生成完的新版本可以覆盖旧 ready。
single-flight 已经保证不会无限堆任务。
```
所以正确策略是:
```text
working 已经开始:继续。
ready 没消费:不等。
新 working 完成:覆盖旧 ready。
下一轮是否继续算:由调度器判断。
```
---
# 9. 主线程 paintEvent 消费规则
主线程 `paintEvent()` 前先尝试消费 `ready`
```cpp
void Chart_Node::prepare_paint() {
std::scoped_lock lock(pixel_lock);
if (pixels.ready.version <= pixels.front.version) {
return;
}
pixels.front.swap(pixels.ready);
versions.painted = pixels.front.version;
}
```
然后只贴 `front`
```cpp
void Chart_Node::paint(QPainter& painter) {
prepare_paint();
painter.drawImage(rect, pixels.front.image);
}
```
`paintEvent()` 不做:
```text
元数据计算
颜色映射
衰减计算
坐标采样
大数组遍历
后台等待
```
它只做:
```text
ready -> front
drawImage(front)
```
如果没有新图,也照样画旧 `front`
因为 Qt 可能因为窗口遮挡、缩放、系统刷新等原因主动触发 `paintEvent()`,不能假设每次 paint 都有新数据。
---
# 10. update 合并
后台发布新图以后,不要每个图都直接 `update()` 整个窗口。
应该由调度器合并刷新区域:
```text
图 A 有新图
图 B 有新图
图 C 有新图
合并 dirty rect
主线程 update(total_dirty_rect)
```
可以加一个 pending 标志,避免重复投递刷新请求:
```cpp
void Render_Scheduler::request_update(Chart_Node* chart) {
dirty_region += chart->rect();
if (paint_request_pending.exchange(true)) {
return;
}
QMetaObject::invokeMethod(widget, [this] {
paint_request_pending.store(false);
QWidget* w = widget;
QRegion region = std::move(dirty_region);
dirty_region = QRegion();
w->update(region);
}, Qt::QueuedConnection);
}
```
这样多个图连续完成渲染,也只触发一次合并后的主线程刷新。
---
# 11. 调度器 tick 流程
调度器定时扫描所有图:
```cpp
void Render_Scheduler::tick() {
std::uint64_t now_ms = current_time_ms();
int submitted_count = 0;
for (Chart_Node* chart : charts) {
if (submitted_count >= max_submit_per_tick) {
break;
}
if (!chart->need_submit(now_ms)) {
continue;
}
chart->submit_render(thread_pool, now_ms);
++submitted_count;
}
}
```
提交任务:
```cpp
void Chart_Node::submit_render(Thread_Pool& pool, std::uint64_t now_ms) {
Render_Input input;
{
std::scoped_lock lock(meta_lock);
input = make_render_input();
versions.submitted = input.version;
versions.running = input.version;
job_state = Render_Job_State::Queued;
last_submit_time = now_ms;
}
pool.enqueue([this, input = std::move(input)]() mutable {
run_render_task(std::move(input));
});
}
```
CPU 任务:
```cpp
void Chart_Node::run_render_task(Render_Input input) {
{
std::scoped_lock lock(job_lock);
job_state = Render_Job_State::Running;
}
Pixel_Frame frame;
frame.version = input.version;
frame.image = render_image(input);
publish_working_frame(std::move(frame));
}
```
---
# 12. 过期任务结果怎么处理
假设:
```text
提交 version 100
CPU 正在算
用户已经改到 version 110
version 100 算完
```
推荐默认策略:
```text
允许发布 version 100
但任务完成后,如果 meta 还是新于 submitted,下一轮马上提交最新 version 110
```
这样用户不会长时间看不到任何结果。
但是不排队算:
```text
101、102、103、104...
```
只算当前正在跑的 100,然后下一轮追最新的 110。
也就是:
```text
旧结果可以短暂显示
旧任务不排队
新任务追最新
```
如果某些图对实时一致性要求特别高,也可以配置成:
```text
任务完成时发现 version < meta_version,直接丢弃结果
```
但默认不建议这么激进,否则用户拖动时可能一直看不到更新。
---
# 13. 最终状态机
每个图的任务状态:
```text
Idle
↓ submit
Queued
↓ thread starts
Running
↓ publish
Idle
```
版本流向:
```text
meta_version
↓ submit
submitted_version
↓ run
running_version
↓ publish
rendered_version
↓ request update
requested_paint_version
↓ paintEvent
painted_version
```
像素缓冲流向:
```text
CPU writes working
↓ publish
working <-> ready
↓ paintEvent
ready <-> front
↓ drawImage(front)
```
如果 ready 没消费,新 working 又来了:
```text
working <-> ready
旧 ready 丢掉
front 不动
```
如果 meta 没变:
```text
不提交任务
不生成图
不 request update
```
如果没有新 ready
```text
paintEvent 只画旧 front
```
---
# 14. 最终设计原则
整个模型可以定成这几条:
```text
1. 每个图有自己的元数据版本。
2. 每个图同一时间最多一个 CPU 渲染任务。
3. 调度器统一扫描所有图,不让每个图自建线程和 timer。
4. 元数据层用快照或者双缓冲。
5. 像素结果层用三缓冲。
6. 三缓冲只管图像内存所有权,不替代版本号。
7. ready 只保存最新待显示图,不做帧队列。
8. 旧 ready 没被消费时,新 working 直接覆盖它。
9. 已经开始的 working 继续算完。
10. 是否继续提交新任务由版本号、single-flight、限速、可见性共同决定。
11. 主线程 paintEvent 只贴 front,不做重计算。
12. update 由调度器合并,不让每个图疯狂 update。
```
一句话总结:
```text
元数据版本号决定要不要算;
single-flight 决定不要堆任务;
CPU 线程池负责把 Meta 算成 Pixel
三缓冲负责 Pixel 的安全交接;
调度器负责多图公平和 update 合并;
paintEvent 只负责贴最新 front。
```
这个模型适合你的场景: **一堆图、多线程后台渲染、高频数据变化、主线程只做轻量显示**