执行与缓存
主线位置:traced 主线的最后一步。手里有一个编译好的
CompiledGraph和一个CpuEngine,Runtime::builder()注册引擎,runtime.prepare(&program, &reads, &[engine.target()])得到Prepared,runtime.run(&mut [&mut engine], &prepared, &reads)得到Vec<Output>,output.into_tensor(&mut engine)得到Tensor。本页讲Runtime里有什么、引擎怎么接进来、准备阶段缓存了什么、键是什么,每次run做了什么,以及时间花在哪里。
sequenceDiagram
participant U as 用户代码
participant R as Runtime
participant C as 准备缓存
participant E as CpuEngine
U->>R: prepare 程序 输入 目标
R->>R: 查目标已注册 补默认输入
R->>R: 读快照 降级 选提供者
R->>C: get_or_prepare 键
C-->>R: 命中或现场准备
R-->>U: Prepared
U->>R: run 引擎 Prepared 输入
R->>R: 查纪元 绑定引擎 查签名
loop 每条命令
R->>E: 执行
end
R-->>U: Vec Output
代码在 tenferro-runtime/src/runtime/:snapshot.rs(Runtime 和配置快照)、engine.rs(EngineTarget、ExecutionEngine、Output)、engine_registration.rs、execution.rs(prepare / run 入口和调度执行器)、preparation.rs、cache.rs、specialization.rs。CPU 引擎的注册在 tenferro-cpu/src/runtime_adapter.rs,引擎本身见 CPU 引擎。本页实验在 exp-tenferro8/rt-graph,release,M2 Max(12 核)、macOS 27、rustc 1.99.0;计时时其他写手也在跑程序,负载在每段输出前注明。本机只有 CPU:CUDA 引擎的异步执行和 synchronize 只从源码读,没有跑过。
Runtime:快照加缓存,没有引擎
Runtime 是 Arc<RuntimeState> 的包装,可以随便 clone、跨线程共享。RuntimeState:
struct RuntimeState {
runtime_id: RuntimeId,
issuer: NonZeroU64,
next_registration_ordinal: AtomicU64,
active: RwLock<Arc<RuntimeConfigSnapshot>>,
published_epoch: AtomicU64,
caches: RuntimeCacheSet<PreparedEntryKey, PreparedProgram>,
}
配置(引擎注册、扩展模块、传输提供者、执行策略)全在 RuntimeConfigSnapshot 里,一旦发布就不改。每次准备开头 snapshot() 拿一份 Arc,后面整个准备都用这一份,别的线程同时改配置也不影响它。改配置用 reconfigure:在旧快照的副本上编辑,校验,然后在写锁下比较「基准快照还是不是当前那个」,是就换上新的 Arc、纪元(RuntimeEpoch)加一,不是就返回 ConcurrentReconfiguration、什么也不发布。
这里没有任何能执行的东西。RuntimeConfigBuilder::register_engine 收的 EngineRegistration 只是一份描述:CPU 的那份由 tenferro_cpu::runtime_engine_registration 生成,引擎 ID 固定是 tenferro-cpu.default.v1,内容是一个无状态的 CpuPreparer(作为逐元素、规约、索引、dot_general、布局五类能力的准备者)、存储类、执行上下文类型 ExecutionContextIdentity::of::<CpuEngine>() 和输入准入规则;从传进来的 &CpuEngine 只读了它的分配域。同一个引擎 ID 再注册一份不同的描述返回 DuplicateEngine,完全相同的那份则什么也不做。einsum、线性代数、FFT 这些运算族另外以扩展模块安装(见扩展算子)。
真正执行的是调用方手里的引擎,它实现 ExecutionEngine。prepare 不要引擎,只要它的 EngineTarget:引擎 ID、具体类型、分配域三样,不含线程、流或内存。实验 run_cache 第 1 段:
== 1. engines
serial target = EngineTarget { engine_id: EngineId("tenferro-cpu.default.v1"), context_identity: ExecutionContextIdentity { type_id: TypeId(0x1a7e04a316d620005046eff3de074040), type_name: "tenferro_cpu::engine::CpuEngine" }, allocation_domain: None }
pooled target == serial target: true
no engine registered: Display: Runtime::prepare (Execution): runtime state failure: no engine "tenferro-cpu.default.v1" is registered with this runtime
no engine registered: Debug: RuntimeState { op: "Runtime::prepare", phase: Execution, message: "no engine \"tenferro-cpu.default.v1\" is registered with this runtime" }
empty targets: Err: Runtime::prepare (Execution): invalid argument targets: prepare needs at least one engine target
run with no engine: Err: Runtime::run (Execution): invalid argument engines: the program was prepared for 1 engines, but 0 were passed
prepared with serial, run on pooled(4): [14.0]
CpuEngine::serial()和带 4 线程池的CpuEngine::new(pool)报告的目标相同,所以为前者准备的Prepared可以在后者上跑。线程数不是目标的一部分,也就不进缓存键。- 没注册引擎时,
prepare在第一步就拒绝,错误是Error::RuntimeState,直接点名哪个引擎 ID 没注册。 run收的引擎个数和顺序必须和prepare时的目标一一对应(EngineSet::bind在任何启动之前检查一次)。
0.7.1 的 Runtime 自己记着后端:runtime_engine_registration(&backend) 把 CpuBackend 克隆一份,既当各类能力的准备者,又当执行用的后端放进注册信息,run_compiled 拿它执行。PR #2209(「feat!: caller-owned execution engines and one prepare/run path」,首次发布于 v0.8.0)把这条路整个换掉。其中的提交 0b590b82「refactor(runtime)!: engines are passed to run; registrations hold no backend」的说明第一条是:「Runtime::prepare(&compiled, inputs, &[EngineTarget]) and Runtime::run(&mut [&mut dyn ExecutionEngine], &Prepared, inputs) -> Vec<Output> replace run_compiled, run_compiled_values, execute_scoped_read_only, prepare_compiled, run_prepared, run_prepared_in, begin_prepared_deferred/PreparedDeferred and submit.」另一条写「Executable registrations carry metadata, context identity and ingress only」,也就是上面看到的那份描述。八个入口合成两个,引擎从 Runtime 里搬到调用方手上:同一个 Prepared 不持有任何引擎状态,几个线程可以各拿自己的引擎同时 run 它。
输入:prepare 和 run 都要给
prepare 和 run 都收一个 &[TensorRead]。两处都经过 resolve_input_reads,按个数分三种:
- 空切片:每个输入都用编译时绑定的默认张量(来自带数据的叶子),有一个没绑定就返回
UnboundPlaceholder。教程里的prepare(&program, &[], ..)/run(.., &[])就是这种。 - 和程序输入一样多:全部用给的张量,默认张量被忽略。顺序是程序的输入顺序,也就是
materialize_merge的压平顺序,不是叶子的创建顺序。 - 介于两者之间:按顺序只填没有默认张量的占位符,有默认的仍用默认。
之后 validate_ordered_input_metadata_values 逐个比对 dtype、秩和形状:Exact 的维度代入所有输入的真实形状求值后必须相等,UpperBound 的不能超过。prepare 时给的输入决定准备成什么样(下一节),run 时给的输入是真正参与计算的。具体形状的程序喂别的长度,两处都会拒绝(第 3 段),符号形状的程序可以(第 4 段):
== 3. explicit inputs with a concrete-shape leaf
shape [3] input -> [3.006005]
prepare with [5]: Err: binding shape mismatch for concrete-shape placeholder: expected [3], got [5]
run [3]-prepared with [5]: Err: binding shape mismatch for concrete-shape placeholder: expected [3], got [5]
输入顺序容易弄错。第 5 段建两个符号叶子 a、b(先 a 后 b),编译 b - a,再按 [10, 1] 喂:
== 5. input order
b - a with inputs [10, 1] -> [9.0, 9.0]; a index = Some(1), b index = Some(0)
b 是程序的第 0 个输入(Sub 先访问它),所以 10 给了 b。CompiledGraph::input_key_index(&a.input_key().unwrap()) 能查到每个叶子的位置,但这个方法是 #[doc(hidden)] 的;文档里给的办法是 compile_with_input_specs,它按调用方列出的顺序排输入,代价是把形状定死(见 TracedTensor)。
输出是 Output:程序算出来的值是 Owned(新的紧凑张量);输出就是某个输入或它的视图时是 Borrowed,不复制,生命周期跟着输入走。into_tensor(&mut engine) 对 Owned 直接交出,对 Borrowed 用引擎的 to_contiguous_read 复制一份。第 6 段:
== 6. borrowed output
reshape to same shape -> Output::Borrowed { dtype: F64, shape: [2] }, ops=1
a * a -> Output::Owned { dtype: F64, shape: [2] }
准备:缓存什么,键是什么
prepare 先查目标:非空、不重复、每个都已注册、具体类型和注册时一致;然后补齐输入、算出输入签名,把目标列表变成一个放置约束,交给 prepare_for_with_compiler_options:检查输入个数和语义程序上的形状守卫,读快照,在 resolve_preparation_context 里过滤出能执行的引擎、把语义程序降级成 ExecProgram(上一页的第二段)、为每个运算选一个提供者,然后才组装缓存键、查缓存。
键的主体是 PreparedRootIdentity:
struct PreparedRootIdentity {
semantic_fingerprint: SemanticFingerprint,
semantic: Arc<SemanticProgram>,
runtime_id: super::RuntimeId,
epoch: super::RuntimeEpoch,
resolved_placement: ResolvedProgramPlacement,
engine_id: EngineId,
registration_identity: RegistrationIdentity,
context_identity: ExecutionContextIdentity,
hardware_class: HardwareClassId,
compiler_options: CompilerOptions,
resolved_planning: ResolvedPlanningKey,
prepare_options: PrepareOptionsKey,
operation_bindings: Box<[PreparedOperationBinding]>,
operation_placements: Box<[ResolvedProgramPlacement]>,
input_endpoints: Box<[ResolvedTransferEndpoint]>,
extension_planning: Box<[ExtensionPlanningIdentity]>,
}
程序的结构(指纹加完整的 SemanticProgram,哈希冲突时逐项比)、哪个运行时的哪个纪元、哪个引擎的哪次注册、编译选项、每个运算选了谁、扩展运算族的规划配置。默认张量不在里面,输入数据也不在里面,引擎实例和它的线程数也不在里面。完整的键 PreparedEntryKey 在它之外再加两样:特化要求(SpecializationRequirements)和输入签名按要求投影出来的值。
特化是 tenferro 自己的设计:一开始要求是 SpecializationRequirements::polymorphic,什么都不看。准备每个运算时,提供者可以回答 NeedsSpecialization(更宽的要求);缓存把这个回答记成一个「重定向」条目,换上新要求再查一次。CPU 提供者的最低要求在 minimum_specialization_requirements:
for (input, entry) in inputs.entries().iter().enumerate() {
let mut builder = InputSpecializationRequirements::builder();
builder.dtype(true).rank(true);
match kind {
CpuPreparedKind::Indexing => {
builder.concrete_dimensions(concrete_axes_for_rank(input, entry.shape().len())?);
}
CpuPreparedKind::DotGeneral => {
builder
.concrete_dimensions(concrete_axes_for_rank(input, entry.shape().len())?)
.layout(LayoutSpecialization::Class);
}
CpuPreparedKind::Elementwise | CpuPreparedKind::Reduction | CpuPreparedKind::Layout => {
}
}
逐元素、规约、布局运算只看 dtype 和秩;索引和 dot_general 还要每一维的具体长度,dot_general 再加布局类别。要求是对整个程序的输入说的,不是对这一个运算的输入。
实验 run_cache 第 2、4b 段把这些都数出来了(entries 是缓存条目,hits / misses 是命中与未命中次数):
== 2. cache: prepare twice, then a second CompiledGraph with the same structure
[fresh] entries=0 hits=0 misses=0 preparations=0 negative_hits=0
[after 1st prepare] entries=2 hits=0 misses=2 preparations=2 negative_hits=0
y = [14.0]
[after 2 runs] entries=2 hits=0 misses=2 preparations=2 negative_hits=0
[after 2nd prepare] entries=2 hits=2 misses=2 preparations=2 negative_hits=0
y2 = [194.0]
[after other program] entries=2 hits=4 misses=2 preparations=2 negative_hits=0
== 4b. symbolic-shape matmul, several shapes
[2,3]x[3,4] -> shape [2, 4]
[2x3x4] entries=2 hits=0 misses=2 preparations=2 negative_hits=0
[2,3]x[3,4] -> shape [2, 4]
[2x3x4] entries=2 hits=2 misses=2 preparations=2 negative_hits=0
[5,3]x[3,4] -> shape [5, 4]
[5x3x4] entries=3 hits=3 misses=3 preparations=3 negative_hits=0
[5,7]x[7,4] -> shape [5, 4]
[5x7x4] entries=4 hits=4 misses=4 preparations=4 negative_hits=0
- 第一次
prepare留下 2 个条目:「多态要求 → 重定向」和「dtype + 秩 → 准备好的程序」。之后每次prepare命中 2 次,先命中重定向、再命中程序。 run不碰缓存:跑两次,计数不变。- 另一份数据、同样结构建出来的
CompiledGraph(y2)直接命中:键里只有结构,没有数据,也没有CompiledGraph对象本身的身份。 - 符号形状的矩阵乘换一个形状就多一个条目:重定向那一条命中,带具体维度的那一条未命中。纯逐元素的符号程序换长度不会(见动脑筋)。
缓存是有界的 LRU:默认最多 128 个条目、64 MiB 逻辑字节,同时最多 16 个键在准备中、64 个键在排队;set_prepared_cache_limits 改,clear_prepared_cache 清。同一个键被几个线程同时请求时只有一个线程准备,其他线程等它(统计里的 waits)。确定性的失败(例如不支持的运算)也会被缓存,再次请求直接返回同一个错误(negative_hits)。
PR #1746(「fix(runtime): prevent prepared cache lost wakeups」,修 issue #1744,首次发布于 v0.5.0)之前,等待者的 wait_once 是这样的:先在缓存锁下登记「我在等这个键」,放开锁返回一个 EntryWaitGuard,之后再拿锁,无条件进入 Condvar::wait。如果准备的线程恰好在「登记」和「进入等待」之间完成并 notify,这次通知就丢了,等待者再也醒不过来。PR 附带的工作日志说,这是 macOS 上一个 CI 任务卡住时查出来的。修复把无条件等待换成循环:拿到锁后先检查「同一个条目的同一次尝试是否还在 Preparing」(same_attempt_is_preparing,0.8.0 还在),只有还在准备时才等,醒来再查。测试用两个只在测试里编译的钩子把等待者卡在登记之后、放准备者先发布,再放行等待者;旧代码在这个测试里一进 Condvar::wait 就 panic,不会真的挂起。
每次 run 做什么
run 整个函数:
pub(super) fn run<'a>(
runtime: &Runtime,
engines: &mut [&mut dyn ExecutionEngine],
prepared: &'a Prepared,
inputs: &[TensorRead<'a>],
) -> Result<Vec<Output<'a>>> {
const CALLER: &str = "Runtime::run";
validate_prepared_runtime(runtime, prepared, CALLER)?;
let mut engines = EngineSet::bind(engines, &prepared.targets, CALLER)?;
let inputs = resolve_input_reads(&prepared.program, inputs, prepared.bound_input_check())?;
let signature = input_signature_reads(&inputs)?;
if *prepared.signature != signature {
super::preparation::validate_execution_signature(
prepared.prepared.as_ref(),
prepared.program.frozen(),
&signature,
)
.map_err(prepare_error)?;
}
let root = prepared.prepared.root();
engines.take_caches(root.needs_extension_caches());
let result = execute_scheduled(root, prepared.prepared.operations(), inputs, &mut engines);
engines.restore_caches();
result
}
- 运行时和纪元。
Prepared记着创建它的运行时 ID 和纪元,对不上就拒绝(下一节)。 - 绑定引擎。 第
i个引擎必须报告第i个目标。 - 补输入、比签名。
Prepared存着准备时的输入签名,签名相同就跳过特化检查;不同就重新投影一遍,看还符不符合准备时的特化。不查缓存,不降级。 - 借扩展缓存。 只有含扩展命令的程序才把引擎自己的扩展缓存(einsum、线代的计划缓存)借出来,用完还回去。
- 执行。
execute_scheduled_slots_into按调度顺序处理每个节点:传输节点搬数据,运算节点交给所在引擎,逐元素区作为一条命令提交,根据last_use及早释放不再用的槽。整张图都在一个位置上(flat)时,值直接放在命令数组里,不按位置暂存和发布。最后按输出顺序收集Output。
第 3 步的差别在第 4 段和 4b 段的最后一行看得到:
prepared for n=3, run n=1000: sum = 2331.8335
prepared for 2x3x4, run 5x7x4: Err: Runtime::run (Execution): runtime state failure: provider contract violation: prepared specialization projection mismatch
逐元素加规约的符号程序只按 dtype 和秩特化,为 3 个元素准备的 Prepared 拿去跑 1000 个元素照样可以。矩阵乘按具体维度特化,为 2×3×4 准备的 Prepared 跑 5×7×4 被拒绝;报错说的是「provider contract violation」,听上去像库的 bug,其实只是这个 Prepared 不适用于新形状,要用新输入重新 prepare。
纪元:配置变了,准备好的东西作废
epoch 是键的一部分,所以 reconfigure 之后所有旧条目都不会再命中;Prepared 也记着纪元,run 时由 validate_prepared_runtime 检查。第 7 段先把 Prepared 拿到另一个 Runtime 上,再换一个执行策略:
== 7. reconfigure
run on another runtime: Err: Runtime::run (Execution): runtime state failure: prepared program belongs to a different runtime
new epoch = RuntimeEpoch(2)
run after reconfigure: Err: Runtime::run (Execution): runtime state failure: prepared epoch RuntimeEpoch(1) does not match current epoch RuntimeEpoch(2)
[prepare after reconfigure] entries=4 hits=0 misses=4 preparations=4 negative_hits=0
旧的 2 个条目还在(等 LRU 淘汰),新纪元又准备了 2 个。
warm_up:第一次执行提前付
Runtime::warm_up 的实现就是一次普通的 run,丢掉输出,再让每个引擎 synchronize:
pub fn warm_up<'a>(
&self,
engines: &mut [&mut dyn super::ExecutionEngine],
prepared: &'a super::Prepared,
inputs: &[TensorRead<'a>],
) -> crate::Result<()> {
let outputs = self.run(engines, prepared, inputs)?;
drop(outputs);
for engine in engines.iter_mut() {
engine.synchronize()?;
}
Ok(())
}
加它的提交 15df8f28(issue #1885,随 PR #2212 进入 v0.8.0)说动机在 GPU 上:「A GPU engine creates vendor handles lazily and compiles a kernel on its first launch」;prepare 不碰引擎,所以不能在那里编译内核。CPU 引擎的 synchronize 是默认的空操作,warm_up 在 CPU 上就是「先跑一次」。第一次跑也确实更贵。实验 warm:sum(exp(a·b) + a),a、b 是 n×n 的 f64,4 线程池,每行一个新进程(负载约 3):
mode=cold n=256 prepare=119.25µs
run 1 = 620.583µs
run 2 = 401.708µs
run 3 = 376.834µs
run 4 = 372.292µs
mode=warm n=256 prepare=107.334µs
warm_up = 633.667µs
run 1 = 414.125µs
run 2 = 389.459µs
run 3 = 393.416µs
run 4 = 372.916µs
mode=cold n=16 prepare=104.208µs
run 1 = 496.25µs
run 2 = 22.25µs
run 3 = 13.25µs
run 4 = 11.25µs
mode=warm n=16 prepare=94.792µs
warm_up = 107.834µs
run 1 = 18.375µs
run 2 = 13µs
run 3 = 11.458µs
run 4 = 11µs
第一次 run 比稳定值多出约 0.25 ms(n=256)到 0.48 ms(n=16),warm_up 把这部分挪到了计时之前。这一段多出来的时间花在 CPU 的哪些惰性初始化上,本页没有拆。(构建后第一个进程的第一次 run 测到过 7.8 ms,那是进程级的冷启动,不在表里。)
时间花在哪里
实验 run_timing 用 16 个 f64 的符号形状输入、acc = acc * x + x 重复 1 次或 20 次再 reduce_sum(3 个或 41 个运算,都不成区,所以命令数等于运算数),每项取 1000 次中的最小值,负载约 3.5:
serial ops= 3 commands= 3 prepare(miss)= 128.4µs first run= 87.1µs | run= 3.9µs prepare(hit)= 12.8µs prepare+run= 15.2µs
serial ops= 41 commands= 41 prepare(miss)= 187.1µs first run= 27.5µs | run= 22.0µs prepare(hit)= 89.0µs prepare+run= 112.8µs
pool(4) ops= 3 commands= 3 prepare(miss)= 31.9µs first run= 15.6µs | run= 4.0µs prepare(hit)= 11.7µs prepare+run= 16.6µs
pool(4) ops= 41 commands= 41 prepare(miss)= 129.2µs first run= 41.5µs | run= 36.8µs prepare(hit)= 89.0µs prepare+run= 127.8µs
pool(12) ops= 3 commands= 3 prepare(miss)= 36.0µs first run= 15.7µs | run= 3.9µs prepare(hit)= 11.3µs prepare+run= 16.2µs
pool(12) ops= 41 commands= 41 prepare(miss)= 135.9µs first run= 44.3µs | run= 36.8µs prepare(hit)= 89.1µs prepare+run= 128.0µs
(prepare(miss) 和 first run 是单次测量,噪声大;第一行是进程里的第一次准备。)
prepare(缓存命中):41 个运算约 89 µs,3 个运算约 12 µs,大致和运算个数成正比,这就是每次重新降级、跑 pass 的代价,和引擎无关。prepare+run(113 µs)≈prepare(hit)(89 µs)+run(22 µs),两边对得上。run:串行引擎 41 条命令约 22 µs,每条约 0.54 µs,16 个元素的实际计算只占很小一部分。4 线程和 12 线程的池上约 37 µs:多线程引擎每条命令多约 0.36 µs,和池的大小无关。- 所以
Prepared要留着反复用:每次都prepare一遍,这两个小程序上准备比执行贵 3 到 4 倍。
命令的固定成本,用上游复现 PR #2222 和 PR #2238 的那个基准(tenferro-linalg/benches/traced_small_ad_overhead.rs)的同样程序再测一遍:qr 加求和损失再对矩阵求 VJP;8 个 f64 上 exp 的链;同一条链在同一个引擎上 eager 执行。实验 cmd_cost,串行引擎,15 批 × 每批 50 次,取每批平均的最小值和中位数,负载约 3.4:
engine threads = 1
grad_sum_qr_vjp/2x2 commands=20 fused/fallback=(0, 0) best= 18.88us median= 19.36us per_command=0.944us
grad_sum_qr_vjp/8x8 commands=20 fused/fallback=(0, 0) best= 20.93us median= 21.18us per_command=1.047us
exp_chain/1[0r/0i] commands=1 fused/fallback=(0, 0) best= 1.98us median= 2.00us per_command=1.975us
exp_chain/25[1r/25i] commands=1 fused/fallback=(0, 900) best= 13.31us median= 13.46us per_command=13.310us
exp_chain/200[1r/200i] commands=1 fused/fallback=(0, 900) best= 93.87us median= 95.41us per_command=93.868us
eager_exp_chain/1 commands=1 fused/fallback=(0, 0) best= 0.32us median= 0.33us per_command=0.320us
eager_exp_chain/25 commands=25 fused/fallback=(0, 0) best= 8.00us median= 8.10us per_command=0.320us
eager_exp_chain/200 commands=200 fused/fallback=(0, 0) best= 66.54us median= 67.60us per_command=0.333us
- qr 的 VJP 程序在 v0.8.0 上是 20 条命令,
2×2和8×8只差 2 µs:时间几乎全是每条命令的固定开销,约 1 µs 一条(其中有 QR 这样的扩展命令)。 - 只有一条命令的程序,一次
run约 2 µs,这是每次run的固定成本。 exp链整段成 1 个区,但 8 个元素远低于 CPU 的融合门槛,每次都退回逐条执行(fallback是累计数):200 条指令约 94 µs,每条约 0.47 µs;同样的 200 次expeager 执行约 67 µs,每次约 0.33 µs。小张量上 traced 程序的逐条执行比 eager 每次调用还多约 0.14 µs。
一个新的 CPU Runtime,xs = from_tensor_symbolic_shape(3 个 f64),程序是 (&xs * &xs)?.reduce_sum(Some(&[0]))?,依次用 3、5、1000 个元素的输入各 prepare 一次再 run。最后 entries、hits、misses 各是多少?
动脑筋答案
== 4. symbolic-shape leaf, one CompiledGraph, several shapes
input metadata shape = [Exact(InputDim { input_idx: 0, axis: 0 })]
n=3: sum = 3.006005
[n=3] entries=2 hits=0 misses=2 preparations=2 negative_hits=0
n=5: sum = 5.02003
[n=5] entries=2 hits=2 misses=2 preparations=2 negative_hits=0
n=1000: sum = 2331.8335
[n=1000] entries=2 hits=4 misses=2 preparations=2 negative_hits=0
entries=2、hits=4、misses=2。程序只有 Mul 和 ReduceSum,CPU 对这两类只要求 dtype 和秩;三次输入都是一维 f64,投影出来的特化值完全一样,只有第一次准备,后两次 prepare 各命中重定向和程序两个条目。输入元素是 1, 1.001, 1.002, …,所以和略大于元素个数。换成第 4b 段的矩阵乘,每个新形状都会多一个条目。
小结
Runtime是Arc里的一份不可变配置快照、一个纪元和一个准备缓存,不持有引擎;引擎注册只是一份描述,CPU 的引擎 ID 是tenferro-cpu.default.v1。prepare只要EngineTarget(ID、类型、分配域),线程数不在其中;没注册引擎时报no engine "tenferro-cpu.default.v1" is registered with this runtime。- 输入的三种给法:空切片用默认张量,给满就全换,给一部分只填占位符;顺序是程序的输入顺序。输出是
Owned或借用输入的Borrowed。 - 缓存键是语义指纹和程序、运行时与纪元、引擎注册、编译选项、每个运算的提供者,再加提供者要求的输入特化;不同数据、同样结构的程序共享条目。每次
prepare都在查缓存前重新降级,41 个运算约 89 µs。 run不查缓存:查纪元、绑定引擎、比签名、执行;签名的特化对不上时报prepared specialization projection mismatch。41 条命令的run在串行引擎上约 22 µs,一次run的固定成本约 2 µs。warm_up是一次run加synchronize;CPU 上第一次run多出约 0.25 到 0.5 ms,warm_up把它挪到计时之前。