Module Ir.Schedule_cache

Process-independent schedule identities and the schedule disk cache

Support for persisting and replaying Schedule.schedule values (docs/proposals: the autotune companion of schedule-ir-optops.md). Schedules embed Indexing.symbols and Tnode.ts whose identities are process-local (global counters), so a schedule value is only meaningful against the one lowering it was built for — and every backend compile lowers afresh. This module gives both a canonical, structural identity:

The same traversal renders the code into a canonical string and digests it. The digest is the safety guarantee: a schedule saved against a digest is only ever replayed onto code with an equal digest, which makes the canonical numbering total and unambiguous by construction — nondeterministic lowering degrades to cache misses, never to a schedule applied to the wrong loop.

Schedule identity pins numerics (gh-ocannl-484): ops holding the reduction-reassociation license (Split_reduce — whose fixed combine tree is a function of num_blocksSwap and Vectorized retypes over accumulations, Tensorize) make the computed values a function of the schedule. Replaying a cached schedule reproduces results bitwise; retuning, clearing the cache, or a digest change may select a different schedule and change low-order bits of reduction results.

type mint_role =
  1. | Split_outer
  2. | Split_inner
  3. | Expand_axis of Base.int
    (*

    The i-th fresh symbol of an Expand_zero.

    *)
  4. | Tensorize_lane
  5. | Partition_seg of Base.int
    (*

    The i-th segment symbol of a Partition.

    *)
  6. | Split_reduce_block
  7. | Split_reduce_inner
  8. | Split_reduce_combine of Base.int
    (*

    The i-th combine symbol of a Split_reduce.

    *)

Which fresh symbol of a schedule op a Minted reference names.

val sexp_of_mint_role : mint_role -> Sexplib0.Sexp.t
val mint_role_of_sexp : Sexplib0.Sexp.t -> mint_role
val compare_mint_role : mint_role -> mint_role -> Base.int
val equal_mint_role : mint_role -> mint_role -> Base.bool
type sym_ref =
  1. | Base of Base.int
  2. | Static of Base.int
  3. | Minted of Base.int * mint_role

A process-independent name for a symbol occurring in a schedule. Base i is the i-th For_loop binder in preorder of the optimized code the schedule applies to; Static k the k-th static index; Minted (op, role) the fresh symbol in role of the op-th (0-based) op of the same saved schedule.

val sexp_of_sym_ref : sym_ref -> Sexplib0.Sexp.t
val sym_ref_of_sexp : Sexplib0.Sexp.t -> sym_ref
val compare_sym_ref : sym_ref -> sym_ref -> Base.int
val equal_sym_ref : sym_ref -> sym_ref -> Base.bool
type saved_optop =
  1. | Split of {
    1. axis : sym_ref;
    2. factor : Base.int;
    3. outer : Low_level.axis_type;
    4. inner : Low_level.axis_type;
    }
  2. | Swap of {
    1. outer : sym_ref;
    2. inner : sym_ref;
    }
  3. | Retype of {
    1. axis : sym_ref;
    2. ty : Low_level.axis_type;
    }
  4. | Unroll of {
    1. axis : sym_ref;
    2. materialize : Base.bool;
    }
  5. | Partition of {
    1. axis : sym_ref;
    2. breakpoints : Base.int Base.list;
    }
  6. | Pad of {
    1. axis : sym_ref;
    2. to_multiple_of : Base.int;
    }
  7. | Stage of {
    1. source : Base.int;
    2. tile_loops : sym_ref Base.list;
    3. shared : Base.bool;
    4. cooperative : Base.int Base.option;
    5. hoisted : Base.bool;
    6. swizzle : Low_level.swizzle_kind Base.option;
      (*

      Serialized only when set (@sexp.option), so pre-swizzle cache files parse.

      *)
    7. pad_stride : Base.int Base.option;
      (*

      Likewise omitted when unset, so pre-gh-481 cache files parse.

      *)
    8. pipeline_depth : Base.int Base.option;
      (*

      None encodes depth 1 and is omitted, so pre-gh-487 cache files parse.

      *)
    9. tile_prec : Ops.prec Base.option;
      (*

      The staged tile's storage precision override (gh-ocannl-575); omitted when unset, so pre-gh-575 cache files parse.

      *)
    }
  8. | Privatize of {
    1. target : Base.int;
    2. over : sym_ref;
    }
  9. | Expand_zero of {
    1. tn : Base.int;
    }
  10. | Tensorize of {
    1. i : sym_ref;
    2. j : sym_ref;
    3. k : sym_ref;
    4. simd_width : Base.int;
    5. tile : Register_tile.t Base.option;
      (*

      The requested C-tile geometry (gh-ocannl-619); omitted when the renderer chooses, so pre-geometry entries stay readable without an entry_version bump.

      *)
    }
  11. | Fuse_epilogue of {
    1. target : Base.int;
    2. shared : Base.bool;
    }
  12. | Split_reduce of {
    1. axis : sym_ref;
    2. target : Base.int;
    3. num_blocks : Base.int;
    }

Schedule.optop with symbols replaced by sym_refs and tensor nodes by their canonical first-occurrence index.

val sexp_of_saved_optop : saved_optop -> Sexplib0.Sexp.t
val saved_optop_of_sexp : Sexplib0.Sexp.t -> saved_optop
val compare_saved_optop : saved_optop -> saved_optop -> Base.int
val equal_saved_optop : saved_optop -> saved_optop -> Base.bool
type saved_schedule = saved_optop Base.list
val sexp_of_saved_schedule : saved_schedule -> Sexplib0.Sexp.t
val saved_schedule_of_sexp : Sexplib0.Sexp.t -> saved_schedule
val compare_saved_schedule : saved_schedule -> saved_schedule -> Base.int
val equal_saved_schedule : saved_schedule -> saved_schedule -> Base.bool
type canonical

The canonical identity of one Low_level.optimized value: the digest, the loop-binder and static-symbol numbering, and the tensor-node numbering.

val canonicalize : ?static_indices:Indexing.static_symbol Base.list -> ?with_placements:Base.bool -> Low_level.optimized -> canonical

Walks the optimized code once in preorder, numbering For_loop binders, first-occurrence tensor nodes (their dims, precision, hoisted-packing eligibility Schedule.hoistable_constant — schedule validity depends on operand constancy, gh-ocannl-470 — and effective placement class from the compile's Ir.Low_level.optimize_ctx — identical code over Local scratch vs an On_device buffer generates different kernels, so same-code different-placement programs must not share cache keys — all enter the digest), and rendering the canonical form. with_placements = false omits the placement classes, giving the structural identity: placement classes can render differently across compilation lineages on byte-identical code, so per-segment schedule matching in fissioned replays keys on structure only. The binder/tensor-node numbering is identical either way. static_indices must be the same list the code was lowered with (Indexing.bound_symbols of the compile's bindings).

val lineage_tag : Tnode.Placements.t -> Low_level.optimize_ctx -> Tnode.t -> Base.string

What one compilation lineage brings to a node's placement decision, as canonicalize_source renders it for its own lineage: the node's effective placement (a prior decision, or the declared intent Tnode.Placements.get falls back to), whether that placement is a heuristic cap's — flippable back by Context.decide_inline, where the same mode imposed by legality or intent is not, so two lineages agreeing on the mode can still pose different refinement surfaces — and the inline / footprint preferences recorded in the lineage's optimize_ctx. Exposed so a caller can tag a further lineage that shapes its decision through node_tag: Train.tune_placements tags the timing context's, whose lineage the arms are measured in.

val canonicalize_source : ?static_indices:Indexing.static_symbol Base.list -> ?node_tag:(Tnode.t -> Base.string) -> lineage:Tnode.Placements.t -> Low_level.optimized -> canonical

The identity of the placement decision problem the lowering answered (gh-ocannl-786): the same walk as canonicalize, over the raw program the specialization was decided over (Ir.Low_level.optimized.source) rather than the decided code, with each node tagged by what the lineage brought to the decision (lineage_tag: effective placement, cap flippability, and the inline / footprint preferences of opt's optimize_ctx). lineage is the placements of the CONTEXT the lowering was decided from (Context.placements) — opt's own table holds this specialization's decisions too, which are exactly what a decision recorded against this identity is. Every node the raw code sets or reads is numbered, so a decision can address a node the default policy inlines away from the decided code; tn_of_ref resolves the number back. node_tag adds what the caller's own decision space distinguishes per node — for Train.tune_placements, whether the node is in the loss's embedded set arm B materializes, since two calls over one computation with different losses pose different problems. Used as the key of the placement-decision store.

val digest : canonical -> Base.string

Hex digest of the canonical rendering. Equal digests mean structurally identical code, hence interchangeable canonical numberings.

val complete : canonical -> Base.bool

false when the code contains constructs the canonical rendering cannot capture (Staged_compilation closures, unbound or shadowed loop symbols). Incomplete canonical forms must not be used as disk-cache keys (distinct programs could collide); within one process they still support to_saved/of_saved round-trips.

val tn_of_ref : canonical -> Base.int -> Tnode.t

The tensor node at a canonical index. Raises Invalid_argument on out-of-range.

Symbol resolution registries

A registry resolves the symbols of a particular compile's code to sym_refs: base and static symbols through its canonical, schedule-minted symbols through entries recorded by to_saved / of_saved. Use it to translate loops of transformed code (base code with a schedule prefix applied) into references a schedule extension can persist.

type registry
val base_registry : canonical -> registry
val resolve : registry -> Indexing.symbol -> sym_ref Base.option
val resolve_tn : registry -> Tnode.t -> Base.int Base.option

Serializes a schedule built against the registry's compile (e.g. by Schedule.default_gpu), recording each op's minted symbols in the returned registry (op indices continue from the number of ops already recorded in the input registry, so extensions of replayed prefixes stay consistent). Raises Invalid_argument when an op references a symbol or tensor node the registry cannot resolve.

Replays a saved schedule against a (fresh) compile's canonical form: base and static references resolve through canonical, minting ops go through the Schedule builders and their fresh symbols are recorded for later references. Raises Invalid_argument on dangling references (canonical mismatch — always guard with digest equality first).

The disk cache

val numerics_tag : Base.unit -> Base.string

A filename-safe short digest of the current Ir.Numerics policy (Ir.Numerics.get, so it tracks Ir.Numerics.set_policy). The policy is not a property of the code — it is consulted at codegen and by the autotune tile-shape choice — so it cannot enter digest, yet a schedule tuned under one policy must never replay under another (gh-ocannl-568: a default-flags run replaying a tf32-tuned tensorized winner measured 5.9x slower than not tuning at all, its mma rendering degraded to the scalar fallback). Hence it enters cache_key and entry.

val codegen_tag : limits:Backend_intf.hardware_limits -> Base.unit -> Base.string

A filename-safe short digest of the codegen environment (gh-ocannl-572): everything consulted when a kernel is rendered, compiled or dispatched, which happens after the lowered code that digest names — so, exactly like numerics_tag, these are invisible to the digest while changing the kernel or what a timing measures, and a winner crowned under one such regime must not replay under another. Three layers: the process-wide gates (the index and pool-slot width large_models, buffer_aliasing's restrict suppression, and the effective routine-logging predicate — which includes the log_level > 1 threshold, so a verbosity bump alone never churns keys — together with the settings that only matter once logging reaches the kernel (prefer_backend_uniformity's logging spelling, the stream-log routing); the whole limits record, which describes the device candidates are generated, rendered and timed against; and, inside it, the backend's own Ir.Backend_intf.hardware_limits.codegen_tag.

type entry = {
  1. version : Base.int;
  2. backend : Base.string;
  3. numerics : Base.string;
    (*

    numerics_tag of the policy the search ran under; redundant with the key, which carries the same tag, so it is a self-description of the file and a guard for a hand-moved entry.

    *)
  4. codegen : Base.string Base.option;
    (*

    codegen_tag of the codegen configuration the search ran under (gh-ocannl-572): the same self-description as numerics. Optional so entries written before this field existed stay readable.

    *)
  5. objective : Base.string Base.option;
    (*

    The autotuner's timing objective (autotune_timing) the search ran under (gh-ocannl-755): the same self-description as numerics and codegen, beside the key's own "timing" component. Optional so entries written before this field existed stay readable — they key differently, so nothing looks them up.

    *)
  6. source_digest : Base.string;
  7. saved : saved_schedule;
  8. segments : (Base.string * saved_schedule) Base.list Base.option;
    (*

    A fissioned winner (docs: per-fission-segment tuning): per-segment schedules keyed by the pre-schedule segment's canonical digest — replay routes each of Schedule.fission_scheduled's `Normal segments through this association (unmatched segments degrade to the empty schedule). None for whole-routine schedules. With segments present, saved is empty except for a split-reduce winner (gh-ocannl-484 task 3), where it holds the whole-routine prelude — resolved against the base canonical form and applied before fission, the segment keys then addressing the post-prelude segmentation.

    *)
  9. finer_fission : Base.bool Base.option;
    (*

    Some true: the segments keys address Schedule.fission_scheduled's arity_cuts (finer) segmentation (gh-ocannl-574); replay must re-segment under the same mode or the keys miss wholesale. Omitted when false, so entries stay byte-stable and pre-gh-574 entries parse without an entry_version bump.

    *)
  10. best_ms : Base.float;
    (*

    The winning candidate's measured time, for diagnostics.

    *)
  11. baseline_ms : Base.float;
    (*

    The unscheduled baseline's measured time, for diagnostics; infinity on GPU backends, where the unparallelized baseline is not dispatched (gh-ocannl-532).

    *)
  12. default_ms : Base.float Base.option;
    (*

    The untuned default pipeline's measured time from the search that wrote the entry, for diagnostics (gh-ocannl-552). None when the default seed was not timed, or for entries written before this field existed — optional so such entries stay readable without an entry_version bump.

    *)
  13. mma_best_ms : Base.float Base.option;
    (*

    The best TIMED tensorized candidate of the search that wrote the entry (gh-ocannl-579), structural rather than label-keyed, and absent when it timed none — or for entries written before this field existed, which therefore replay as "nothing is known". A measurement of the PROGRAM, like best_ms and baseline_ms and under the same key regime, which is what makes it replayable: the flip chain's profitability term reads it, so without it a warm cache would rank the decision surface differently from the cold run that measured it.

    *)
  14. default_fingerprint : Base.string Base.option;
    (*

    Schedule.default_schedule_fingerprint at store time, present iff default_ms is: the cache key covers only the source digest and the backend, so a config change can redefine what "the default pipeline" means without missing the cache. A replaying process compares fingerprints and drops a stale default_ms (the schedule itself stays valid — only this diagnostic is config-relative).

    *)
}
val sexp_of_entry : entry -> Sexplib0.Sexp.t
val entry_of_sexp : Sexplib0.Sexp.t -> entry
val entry_version : Base.int

Bumped when the canonical rendering or the saved-schedule format changes; stale entries are ignored by lookup.

val objective_tag : Base.unit -> Base.string

The autotuner's configured timing objective (autotune_timing), normalized to the spelling a cache key carries (gh-ocannl-755). What cache_key uses when its caller supplies none — so a caller with no resolved mode of its own keys against the one a search in this process would use, rather than restating a default that could drift. An unknown spelling passes through: the setting is validated where it is acted on, and this only has to keep unlike regimes apart.

val key_components : Base.string Base.list

The named components a cache_key is built from, in order: "digest", "backend", "numerics", "codegen", "pool", "device", "timing". The list drives cache_key rather than describing it, so it is a complete and current enumeration of the cache's identity — which is what the digest-completeness registry classifies configuration keys against (gh-ocannl-572, test/operations/digest_completeness).

val cache_key : ?objective:Base.string -> timing_identity:Backend_intf.timing_identity Base.option -> limits:Backend_intf.hardware_limits -> canonical -> backend:Base.string -> Base.string Base.option

None if the concrete timing identity is unavailable; all cache I/O with None is a no-op, including directory creation, locking and regime changes. Otherwise a filename-safe cache key: the digest, the backend name, numerics_tag of the current numerics policy, codegen_tag of the codegen configuration (including limits.codegen_tag, the compiling backend's own contribution), the worker-pool signature (limits.worker_pool_tag, gh-ocannl-530: CPU crowns do not transfer across pools), and the autotuner's timing objective (objective, defaulting to objective_tag).

The objective is a key component because the two objectives crown DIFFERENT candidates (gh-ocannl-755, measured): an entry crowned under isolated timing is not the answer to a search asking about queued timing, and the times it stores are readings of a different quantity, which a replay would copy into the reading process's report under its own label. CUDA/HIP queued keys carry policy generation 2 (gh-ocannl-892), invalidating winners measured under the old depth-200/short-batch regime without invalidating the unchanged cc/Metal regimes. objective is for the caller that resolved a mode explicitly rather than from configuration (Autotune.tune's ?timing); everyone else wants the default. The backend-supplied components arrive as the whole limits record rather than one optional argument each, so a component added there reaches every call site instead of defaulting to absent at the ones that were not updated (gh-ocannl-572). The mandatory timing_identity separately identifies concrete device capabilities and optional observed toolchain metadata; it does not change construction limits or promise complete runtime/driver provenance.

val cache_regime_version : Base.int

Version of the filename-key regime recorded once per cache directory (gh-ocannl-835). Bump it when the schema of key_components changes. This is independent of entry_version, which versions the payload stored at one key.

val regime_stamp_filename : Base.string

The directory-local file containing cache_regime_version. Exposed as part of the on-disk cache format, including for tools and synthetic cache-directory tests; callers should not edit a live cache's stamp.

val regime_lock_filename : Base.string

The permanent record-lock file shared by cache-opening processes. Exposed as part of the on-disk cache format for synthetic cache-directory tests; callers must never unlink a live cache's lock file.

val store : dir:Base.string -> key:Base.string Base.option -> entry -> Base.unit

Writes the entry to dir/key.sexp, creating dir (and parents) if missing. Publication goes through Utils.Atomic_file, so concurrent writers tolerate each other (last write wins) and a failed write or commit removes its own staging artifact and leaves an earlier complete entry intact. Before writing, cache-open serializes participating processes on a permanent lock file: an older or absent regime stamp sweeps every .sexp entry and is atomically replaced by the current stamp; a malformed or newer stamp refuses the operation without changing its stamp or entries. Holding that lock through the write prevents a concurrent regime transition from deleting the new entry. A filesystem refusal is not propagated: the cache is an optimization, and an entry that could not be written is a future miss rather than a failed run.

val lookup : dir:Base.string -> key:Base.string Base.option -> entry Base.option

None on missing file, unparsable content, version/digest mismatch, or a refused cache-open. Process-level failures (Out_of_memory, Sys.Break, Stack_overflow) propagate rather than reading as a miss (gh-ocannl-786): an interrupt during a lookup must not start the search the lookup was about to spare.

Together with store this participates in the same permanent record lock and regime-stamp protocol described there, and sweeps the cache directory's crash-stale staging files (Utils.Atomic_file.cleanup_stale_once) once per directory per process. Every participating reader and writer holds the lock through its entry I/O, so same-version processes race benignly and an older participating binary refuses after a newer one advances the stamp. The OS releases the record lock on process death; the lock file is never unlinked, avoiding an inode-replacement race. A binary predating this protocol does not participate and must not share a live directory during an upgrade.

The placement-decision store

The durable record of what Train.tune_placements decided (gh-ocannl-786), beside the schedule entries in the same directory: same lock, same regime stamp, same atomic commit, and the same key components — the decision was made by timing kernels under the numerics, codegen, pool and objective regime the schedule entries key on. The key's digest is canonicalize_source's: the decision problem, with the decision itself deliberately outside it. Placement stays outside the schedule value (a schedule is keyed by the placement-aware digest, so folding placements into it would be circular); this store is the other half of the persistence the schedule cache gives schedules.

type placement_flip = {
  1. node : Base.int;
  2. flip : [ `Materialize | `Inline | `Footprint ];
}

One accepted flip of the greedy inline refinement: the node by its canonicalize_source index, and the direction (Context.decide_materialized / Context.decide_inline / Context.decide_footprint).

val sexp_of_placement_flip : placement_flip -> Sexplib0.Sexp.t
val placement_flip_of_sexp : Sexplib0.Sexp.t -> placement_flip
val compare_placement_flip : placement_flip -> placement_flip -> Base.int
val equal_placement_flip : placement_flip -> placement_flip -> Base.bool
type placement_decision =
  1. | Default
  2. | Materialize_all
  3. | Refined of placement_flip Base.list

What shipped: arm A as the default policy decided it, arm B with every embedded node of the loss materialized (re-derived from the loss at replay, so the nodes need no structural address), or arm A refined by the accepted flips in chain order.

val sexp_of_placement_decision : placement_decision -> Sexplib0.Sexp.t
val placement_decision_of_sexp : Sexplib0.Sexp.t -> placement_decision
val compare_placement_decision : placement_decision -> placement_decision -> Base.int
val equal_placement_decision : placement_decision -> placement_decision -> Base.bool
type placement_entry = {
  1. version : Base.int;
  2. backend : Base.string;
  3. numerics : Base.string;
    (*

    numerics_tag at store time: self-description, like entry's.

    *)
  4. codegen : Base.string;
    (*

    codegen_tag at store time.

    *)
  5. objective : Base.string;
    (*

    The timing objective the arms were compared under.

    *)
  6. problem_digest : Base.string;
    (*

    The key's digest, for a hand-moved file.

    *)
  7. decision : placement_decision;
  8. outcome_digest : Base.string;
    (*

    digest of canonicalize (placement-aware, the schedule cache's own digest) over the lowering the decision produces — the replay guard: an entry whose decision no longer reproduces the program it was measured on (a lineage that inherits differently, a virtualization cap that moved, a flip naming a node the fresh lowering treats otherwise) is stale and re-tuned rather than applied.

    *)
  9. shipped_ms : Base.float;
    (*

    The shipped search's best_ms, for diagnostics.

    *)
  10. arm_a_ms : Base.float;
    (*

    Arm A's best_ms; infinity for a failed arm.

    *)
  11. arm_b_ms : Base.float;
    (*

    Arm B's.

    *)
}
val sexp_of_placement_entry : placement_entry -> Sexplib0.Sexp.t
val placement_entry_of_sexp : Sexplib0.Sexp.t -> placement_entry
val placement_entry_version : Base.int

Bumped when placement_entry or canonicalize_source's rendering changes; stale entries are ignored by lookup_placements.

val placement_key : ?objective:Base.string -> timing_identity:Backend_intf.timing_identity Base.option -> limits:Backend_intf.hardware_limits -> canonical -> backend:Base.string -> Base.string Base.option

cache_key over a canonicalize_source identity, in the placement store's own filename space.

val store_placements : dir:Base.string -> key:Base.string Base.option -> placement_entry -> Base.unit

store's protocol, for a placement entry.

val lookup_placements : dir:Base.string -> key:Base.string Base.option -> placement_entry Base.option

lookup's protocol, for a placement entry.

val shipped_label : placement_decision -> Base.string

The on_ship label of a decision: "A", "B" or "flip".