Rust in Everything
基于 tenferro-runtime 0.8.0

执行与缓存

主线位置: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 在任何启动之前检查一次)。
📝NOTE
冷知识:会话换成了借出的引擎

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)。

📝NOTE
冷知识:等待准备结果的线程会睡过头

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
}
  1. 运行时和纪元。 Prepared 记着创建它的运行时 ID 和纪元,对不上就拒绝(下一节)。
  2. 绑定引擎。 第 i 个引擎必须报告第 i 个目标。
  3. 补输入、比签名。 Prepared 存着准备时的输入签名,签名相同就跳过特化检查;不同就重新投影一遍,看还符不符合准备时的特化。不查缓存,不降级。
  4. 借扩展缓存。 只有含扩展命令的程序才把引擎自己的扩展缓存(einsum、线代的计划缓存)借出来,用完还回去。
  5. 执行。 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 次 exp eager 执行约 67 µs,每次约 0.33 µs。小张量上 traced 程序的逐条执行比 eager 每次调用还多约 0.14 µs。
❗IMPORTANT
动脑筋:符号形状的逐元素程序换三种长度,缓存里有几个条目?

一个新的 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 把它挪到计时之前。

💬 关联讨论

发起讨论
加载中...