blob: 35f4814aab004e8d7d23918edd439529f3566da5 [file]
// Copyright 2026 The IREE Authors
//
// Licensed under the Apache License v2.0 with LLVM Exceptions.
// See https://llvm.org/LICENSE.txt for license information.
// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
#ifndef IREE_ASYNC_FRONTIER_H_
#define IREE_ASYNC_FRONTIER_H_
#include "iree/base/api.h"
#ifdef __cplusplus
extern "C" {
#endif // __cplusplus
//===----------------------------------------------------------------------===//
// Causal domain
//===----------------------------------------------------------------------===//
// Identifies the category of timeline participant represented by an axis.
// Each domain has its own ordinal encoding scheme within the axis (see
// iree_async_axis_t documentation for the full encoding).
enum iree_async_causal_domain_e {
// Hardware or virtual device queue.
IREE_ASYNC_CAUSAL_DOMAIN_QUEUE = 0,
// Collective communication channel.
IREE_ASYNC_CAUSAL_DOMAIN_COLLECTIVE = 1,
// Epilogue kernel timeline.
IREE_ASYNC_CAUSAL_DOMAIN_EPILOGUE = 2,
// Host-side virtual queue (thread or execution context).
IREE_ASYNC_CAUSAL_DOMAIN_HOST = 3,
};
typedef uint8_t iree_async_causal_domain_t;
//===----------------------------------------------------------------------===//
// Axis
//===----------------------------------------------------------------------===//
// A 64-bit identifier for a timeline participant in the causal ordering system.
//
// Every independent source of progress in the system — every GPU queue, every
// collective channel, every host thread — is an "axis." Each axis has a
// monotonically increasing timeline value (epoch). An axis at epoch N means
// "all work submitted to this participant up to sequence point N has
// completed."
//
// Axes are the building blocks of frontiers. A frontier says "axis A is at
// epoch 5, axis B is at epoch 12" — meaning the referenced event depends on
// both A having completed 5 operations and B having completed 12.
//
// The encoding is structured for debuggability and deterministic allocation:
//
// ┌────────────┬────────────┬────────────┬──────────────────────────────┐
// │ Bits 63-56 │ Bits 55-48 │ Bits 47-40 │ Bits 39-0 │
// ├────────────┼────────────┼────────────┼──────────────────────────────┤
// │ Session │ Machine │ Domain │ Ordinal │
// │ Epoch │ Index │ │ (structured by domain) │
// │ (8 b) │ (8 b) │ (8 b) │ (40 b) │
// └────────────┴────────────┴────────────┴──────────────────────────────┘
//
// Session epoch: Increments per session creation. Prevents ABA problems
// across sessions — an axis from session 3 can never be confused with
// one from session 4 even if machine/domain/ordinal happen to match.
//
// Machine index: 0-255 within a session. Assigned by the coordinator at
// session join time. Identifies which physical machine this axis lives on.
//
// Domain: Which category of timeline participant (see
// iree_async_causal_domain_t).
//
// Ordinal: Domain-specific structured identifier:
// QUEUE domain:
// [39:32] device_index (8 bits) — 256 devices per machine
// [31:24] queue_index (8 bits) — 256 queues per device
// [23:0] reserved (24 bits)
// COLLECTIVE domain:
// [39:0] channel_id (40 bits) — coordinator-assigned
// HOST domain:
// [39:0] context_id (40 bits) — thread/context identifier
//
// Axes are computed deterministically from logical topology: the same topology
// description always produces the same axis values, regardless of which
// physical GPU fills each slot. This makes frontiers directly comparable across
// machines without translation.
//
// Example axis values (session 1, machine 3):
// GPU queue 0 on device 1: 0x01_03_00_01_00_000000
// Collective channel 42: 0x01_03_01_00_00_00002A
// Host context 7: 0x01_03_03_00_00_000007
typedef uint64_t iree_async_axis_t;
// Constructs an axis from its four components. The ordinal is masked to 40
// bits; the caller is responsible for encoding domain-specific structure within
// the ordinal.
static inline iree_async_axis_t iree_async_axis_make(
uint8_t session_epoch, uint8_t machine_index,
iree_async_causal_domain_t domain, uint64_t ordinal) {
return ((uint64_t)session_epoch << 56) | ((uint64_t)machine_index << 48) |
((uint64_t)domain << 40) | (ordinal & 0xFFFFFFFFFFull);
}
// Constructs a QUEUE-domain axis from device and queue indices.
// This is the most common axis construction — one per GPU queue in the system.
static inline iree_async_axis_t iree_async_axis_make_queue(
uint8_t session_epoch, uint8_t machine_index, uint8_t device_index,
uint8_t queue_index) {
uint64_t ordinal =
((uint64_t)device_index << 32) | ((uint64_t)queue_index << 24);
return iree_async_axis_make(session_epoch, machine_index,
IREE_ASYNC_CAUSAL_DOMAIN_QUEUE, ordinal);
}
// Extracts the session epoch from an axis.
static inline uint8_t iree_async_axis_session(iree_async_axis_t axis) {
return (uint8_t)(axis >> 56);
}
// Extracts the machine index from an axis.
static inline uint8_t iree_async_axis_machine(iree_async_axis_t axis) {
return (uint8_t)(axis >> 48);
}
// Extracts the causal domain from an axis.
static inline iree_async_causal_domain_t iree_async_axis_domain(
iree_async_axis_t axis) {
return (iree_async_causal_domain_t)((axis >> 40) & 0xFF);
}
// Extracts the raw 40-bit ordinal from an axis. Interpretation depends on the
// domain (see iree_async_axis_t encoding documentation).
static inline uint64_t iree_async_axis_ordinal(iree_async_axis_t axis) {
return axis & 0xFFFFFFFFFFull;
}
// Extracts the device index from a QUEUE-domain axis.
// Undefined behavior if the axis is not in the QUEUE domain.
static inline uint8_t iree_async_axis_device_index(iree_async_axis_t axis) {
return (uint8_t)(iree_async_axis_ordinal(axis) >> 32);
}
// Extracts the queue index from a QUEUE-domain axis.
// Undefined behavior if the axis is not in the QUEUE domain.
static inline uint8_t iree_async_axis_queue_index(iree_async_axis_t axis) {
return (uint8_t)(iree_async_axis_ordinal(axis) >> 24);
}
//===----------------------------------------------------------------------===//
// Frontier
//===----------------------------------------------------------------------===//
// A single (axis, epoch) pair within a frontier. States that the given axis
// must have reached at least the given epoch for this entry to be satisfied.
typedef struct iree_async_frontier_entry_t {
// Timeline participant being constrained.
iree_async_axis_t axis;
// Minimum completed epoch required on the axis.
uint64_t epoch;
} iree_async_frontier_entry_t;
// Fields shared by all frontier header representations. This is used by both
// the variable-length frontier type and fixed-capacity frontier storage so that
// field comments and layout stay in one place.
#define IREE_ASYNC_FRONTIER_HEADER_FIELDS \
/* Number of valid entries in the frontier. */ \
uint8_t entry_count; \
/* Reserved bytes preserving the fixed frontier header layout. */ \
uint8_t reserved[7]
// Frontiers are followed by entries containing uint64_t values. wasm32 keeps
// pointers at 4-byte alignment but still aligns those entries to 8 bytes.
#define IREE_ASYNC_FRONTIER_ALIGNMENT 8
// Fixed-size frontier header. Useful for embedding a frontier header before
// trailing entry storage in another variable-size record.
typedef struct iree_alignas(IREE_ASYNC_FRONTIER_ALIGNMENT)
iree_async_frontier_header_t {
IREE_ASYNC_FRONTIER_HEADER_FIELDS;
} iree_async_frontier_header_t;
// A frontier is a vector clock: a set of (axis, epoch) pairs that together
// define a causal position in the distributed system. A frontier answers the
// question: "what must have happened before this point?"
//
// ## The problem frontiers solve
//
// In a multi-device, multi-machine system, work proceeds on many independent
// timelines simultaneously. A single buffer might be:
// - Produced by GPU0's compute queue (dispatch 47)
// - Transferred via network (send sequence 12)
// - Have its metadata updated by the host (context 3, step 8)
//
// Before you can safely read that buffer, ALL THREE of those timelines must
// have reached their respective points. A single semaphore tracks one timeline.
// A frontier tracks all of them at once.
//
// ## Visual intuition
//
// Think of time flowing left-to-right on multiple parallel tracks:
//
// GPU0 queue0: ──①──②──③──④──⑤──⑥──⑦──▶
// GPU0 queue1: ──①──②──③──④──▶
// GPU1 queue0: ──①──②──③──④──⑤──▶
// Network: ──①──②──③──▶
//
// A frontier is a vertical "cut" across these timelines:
//
// GPU0 queue0: ──①──②──③──④──⑤──│──⑥──⑦──▶
// GPU0 queue1: ──①──②──③──│──④──▶
// GPU1 queue0: ──①──②──③──④──│──⑤──▶
// Network: ──①──②──│──③──▶
// │
// frontier F = {
// (GPU0/q0, 5),
// (GPU0/q1, 3),
// (GPU1/q0, 4),
// (Network, 2),
// }
//
// Everything to the LEFT of the cut has happened. Everything to the RIGHT
// may or may not have happened yet. The frontier IS the cut.
//
// ## Causal ordering (happens-before)
//
// Two frontiers define a partial order:
//
// F1 BEFORE F2: Every axis in F1 is at the same or earlier epoch than in F2,
// and at least one is strictly earlier. F1's "cut" is entirely
// to the left of F2's. Anything that depends on F1 being
// satisfied also satisfies F2 (but not vice versa).
//
// F1 AFTER F2: The reverse. F2 happens-before F1.
//
// CONCURRENT: Neither dominates. F1 is ahead on some axes, F2 is ahead on
// others. There is no causal relationship — these events could
// have happened in either order (or simultaneously).
//
// EQUAL: Same cut. Same causal position.
//
// Example:
// F1 = {(A, 3), (B, 5)}
// F2 = {(A, 4), (B, 7)} → F1 BEFORE F2 (both axes advanced)
// F3 = {(A, 5), (B, 3)} → F1 CONCURRENT with F3 (A advanced, B went back)
//
// ## Relationship to semaphores
//
// Each axis in a frontier corresponds to one iree_async_semaphore_t timeline.
// A frontier {(axis_A, 5), (axis_B, 12)} is equivalent to:
// "wait for semaphore_A >= 5 AND semaphore_B >= 12"
//
// The frontier is a COMPACT REPRESENTATION of a multi-semaphore wait. It adds:
// - Causal reasoning (compare, merge) that individual semaphores don't have.
// - Wire-format efficiency (one frontier vs. N semaphore references).
// - Transitive causality propagation (merge preserves all dependencies).
//
// ## Merge (join in the lattice)
//
// Merging two frontiers produces the LEAST UPPER BOUND: the earliest point
// that is guaranteed to be after both inputs. This is component-wise max:
//
// merge({(A,3), (B,5)}, {(A,7), (C,2)}) = {(A,7), (B,5), (C,2)}
//
// Merge is used when an operation depends on two independent predecessors:
//
// GPU produces buffer → frontier {(GPU/q0, 47)}
// Network receives data → frontier {(Net, 12)}
// Operation needs both → merge = {(GPU/q0, 47), (Net, 12)}
//
// Merge is associative and commutative (it's a lattice join).
//
// ## Wire format usage
//
// Frontiers are the core of the remoting protocol's consistency guarantees.
// Every command sent across the network carries a frontier:
//
// "Execute this dispatch, but only after frontier F is satisfied."
//
// The receiver maps the frontier's axes to local semaphores (via the axis
// table) and waits for all of them. This is how GPU→GPU synchronization works
// across machines without a centralized lock server.
//
// ## Storage and allocation
//
// Frontiers are variable-length value types (VLA). Callers provide storage:
// - Stack: for small frontiers (≤4 entries is the common case).
// - Slab: via iree_async_frontier_size() for overflow-checked sizing.
// - Inline: embedded in protocol messages with trailing entry data.
//
// Entries MUST be sorted by axis (ascending) for O(n) comparison and merge.
// The entry_count is uint8_t: maximum 255 entries per frontier (sufficient
// for rack-scale systems with hundreds of queues).
typedef struct iree_async_frontier_t {
IREE_ASYNC_FRONTIER_HEADER_FIELDS;
// Variable-length frontier entries sorted by ascending axis.
iree_async_frontier_entry_t entries[];
} iree_async_frontier_t;
static_assert(offsetof(iree_async_frontier_t, entries) ==
sizeof(iree_async_frontier_header_t),
"frontier header must match frontier FAM offset");
static_assert(IREE_ASYNC_FRONTIER_ALIGNMENT >=
iree_alignof(iree_async_frontier_entry_t),
"frontier alignment must cover frontier entry alignment");
static_assert(iree_alignof(iree_async_frontier_header_t) ==
iree_alignof(iree_async_frontier_t),
"frontier header must match frontier alignment");
#define IREE_ASYNC_FIXED_FRONTIER_TYPE(type_name, entry_capacity) \
typedef struct type_name { \
IREE_ASYNC_FRONTIER_HEADER_FIELDS; \
/* Fixed-capacity frontier entries sorted by ascending axis. */ \
iree_async_frontier_entry_t entries[(entry_capacity)]; \
} type_name; \
static_assert(offsetof(type_name, entries) == sizeof(iree_async_frontier_t), \
#type_name " entries must alias iree_async_frontier_t FAM"); \
static_assert( \
iree_alignof(type_name) == iree_alignof(iree_async_frontier_t), \
#type_name " must match iree_async_frontier_t alignment")
// A frontier with exactly one (axis, epoch) entry, sized for stack allocation.
// Memory-layout-compatible with iree_async_frontier_t when cast — the entries
// fixed array begins at the same offset as the FAM in iree_async_frontier_t.
//
// This is the common case for single-queue drivers: one queue produces one axis
// entry per signal. Declare on the stack and use the as_frontier accessor for
// API calls that take iree_async_frontier_t*.
IREE_ASYNC_FIXED_FRONTIER_TYPE(iree_async_single_frontier_t, 1);
// Casts fixed-capacity frontier storage to the variable-size frontier view.
static inline iree_async_frontier_t* iree_async_fixed_frontier_as_frontier(
void* storage) {
return (iree_async_frontier_t*)storage;
}
// Casts fixed-capacity frontier storage to the const variable-size view.
static inline const iree_async_frontier_t*
iree_async_fixed_frontier_as_const_frontier(const void* storage) {
return (const iree_async_frontier_t*)storage;
}
// Returns a pointer to the layout-compatible iree_async_frontier_t within a
// single-entry frontier. Valid for all frontier APIs (compare, merge, etc.).
static inline iree_async_frontier_t* iree_async_single_frontier_as_frontier(
iree_async_single_frontier_t* single) {
return iree_async_fixed_frontier_as_frontier(single);
}
// Returns a const pointer to the layout-compatible iree_async_frontier_t.
static inline const iree_async_frontier_t*
iree_async_single_frontier_as_const_frontier(
const iree_async_single_frontier_t* single) {
return iree_async_fixed_frontier_as_const_frontier(single);
}
// Initializes a single-entry frontier for the given axis and epoch.
static inline void iree_async_single_frontier_initialize(
iree_async_single_frontier_t* frontier, iree_async_axis_t axis,
uint64_t epoch) {
frontier->entry_count = 1;
memset(frontier->reserved, 0, sizeof(frontier->reserved));
frontier->entries[0].axis = axis;
frontier->entries[0].epoch = epoch;
}
// Computes the total byte size needed to store a frontier with |entry_count|
// entries using overflow-checked arithmetic. Use this for slab or heap
// allocation:
//
// iree_host_size_t size = 0;
// IREE_RETURN_IF_ERROR(iree_async_frontier_size(3, &size));
// iree_async_frontier_t* f = NULL;
// IREE_RETURN_IF_ERROR(iree_allocator_malloc(allocator, size, (void**)&f));
// iree_async_frontier_initialize(f, 3);
// f->entries[0] = (iree_async_frontier_entry_t){gpu_axis, 5};
// f->entries[1] = (iree_async_frontier_entry_t){net_axis, 12};
// f->entries[2] = (iree_async_frontier_entry_t){host_axis, 8};
static inline iree_status_t iree_async_frontier_size(
iree_host_size_t entry_count, iree_host_size_t* out_size) {
return IREE_STRUCT_LAYOUT(
sizeof(iree_async_frontier_t), out_size,
IREE_STRUCT_FIELD_FAM(entry_count, iree_async_frontier_entry_t));
}
// Initializes a pre-allocated frontier with the given entry count.
// After this call, the caller must fill entries[0..entry_count-1] with
// (axis, epoch) pairs IN SORTED ORDER BY AXIS (ascending).
//
// WARNING: The sorted-entries invariant is critical for correctness.
// iree_async_frontier_compare() and iree_async_frontier_merge() use a
// merge-scan algorithm that relies on sorted order. Unsorted entries cause
// SILENT INCORRECT CAUSAL ORDERING — operations may execute before their
// dependencies complete, leading to data corruption. There is no runtime
// check on the hot path (compare/merge are O(n) and must stay cheap).
// Use iree_async_frontier_validate() in debug/test code to catch violations.
static inline void iree_async_frontier_initialize(
iree_async_frontier_t* frontier, uint8_t entry_count) {
frontier->entry_count = entry_count;
memset(frontier->reserved, 0, sizeof(frontier->reserved));
}
// Validates that a frontier's entries satisfy the sorted-by-axis invariant.
// Returns iree_ok_status() if valid, or an error describing the violation.
// This is intended for debug assertions and test validation — NOT for hot
// paths. Call after constructing a frontier to catch programming errors early.
//
// Checks:
// - Entries are in strictly ascending axis order (no duplicates).
// - No zero-epoch entries (epoch 0 is meaningless — the axis hasn't
// advanced, so it should not be in the frontier at all).
//
// Usage:
// iree_async_frontier_initialize(f, 3);
// f->entries[0] = ...;
// f->entries[1] = ...;
// f->entries[2] = ...;
// IREE_ASSERT_OK(iree_async_frontier_validate(f)); // Debug only.
iree_status_t iree_async_frontier_validate(
const iree_async_frontier_t* frontier);
//===----------------------------------------------------------------------===//
// Frontier comparison
//===----------------------------------------------------------------------===//
// Result of comparing two frontiers. Defines the causal relationship between
// the events they represent.
//
// The comparison follows vector clock semantics:
// - BEFORE: a is dominated by b (a happens-before b).
// - AFTER: b is dominated by a (b happens-before a).
// - CONCURRENT: neither dominates (no causal relationship).
// - EQUAL: identical causal position.
//
// Axes present in one frontier but not the other are treated as epoch 0 in
// the missing frontier. This means a frontier that mentions an axis is always
// AFTER a frontier that doesn't mention it (assuming epoch > 0).
enum iree_async_frontier_comparison_e {
IREE_ASYNC_FRONTIER_EQUAL = 0,
IREE_ASYNC_FRONTIER_BEFORE = 1,
IREE_ASYNC_FRONTIER_AFTER = 2,
IREE_ASYNC_FRONTIER_CONCURRENT = 3,
};
typedef uint8_t iree_async_frontier_comparison_t;
// Compares two frontiers for causal ordering. Both must have entries sorted by
// axis. Returns the relationship of |a| relative to |b|:
// BEFORE means "a happens-before b"
// AFTER means "b happens-before a"
//
// Runs in O(n + m) time where n and m are the entry counts (merge-scan of two
// sorted arrays).
//
// Example:
// a = {(axis_X, 3), (axis_Y, 5)}
// b = {(axis_X, 3), (axis_Y, 7)}
// compare(a, b) → BEFORE (Y advanced from 5 to 7, X stayed)
iree_async_frontier_comparison_t iree_async_frontier_compare(
const iree_async_frontier_t* a, const iree_async_frontier_t* b);
// Merges |source| into |target| by taking the component-wise maximum epoch
// for each axis. Axes in |source| not present in |target| are added. Both
// must have entries sorted by axis.
//
// The result is the LEAST UPPER BOUND (join) in the causal lattice: the
// earliest frontier that is causally after both |target| and |source|.
//
// |target_capacity| is the maximum number of entries |target|'s allocation can
// hold. If the merged result would exceed capacity, the merge fails and
// |target| is left unchanged. Callers must size capacity to accommodate
// worst-case merge (sum of unique axes across all sources). 255 entries
// (uint8_t max) is sufficient for rack-scale systems.
//
// Performance: three paths in order of expected frequency:
// 1. Source empty: O(1) no-op.
// 2. Identical axis sets: O(n) epoch-max in place, zero entry movement.
// 3. Different axis sets: O(n+m) in-place right-to-left merge, no scratch.
//
// Returns true on success (|target| modified, entry_count updated).
// Returns false if the merged count would exceed |target_capacity|.
//
// Example:
// target = {(A, 3), (B, 5)} capacity = 4
// source = {(A, 7), (C, 2)}
// after merge: target = {(A, 7), (B, 5), (C, 2)} entry_count = 3
bool iree_async_frontier_merge(iree_async_frontier_t* target,
uint8_t target_capacity,
const iree_async_frontier_t* source);
// Merges |source| into |target| like iree_async_frontier_merge() and reports
// whether the old |target| frontier was already dominated by |source|.
//
// If the merge succeeds and |*out_source_dominates_target| is true, the merged
// |target| frontier is exactly equal to |source|. This lets producers publish a
// compact "one producer epoch covers the full frontier" summary without doing a
// second post-merge equality scan.
//
// If the merge fails due to capacity overflow, returns false, leaves |target|
// unchanged, and sets |*out_source_dominates_target| to false.
bool iree_async_frontier_merge_and_test_source_dominance(
iree_async_frontier_t* target, uint8_t target_capacity,
const iree_async_frontier_t* source, bool* out_source_dominates_target);
// Returns true if every entry in |frontier| is satisfied by the corresponding
// epoch in |current_epochs|. A frontier is satisfied when all of its axes have
// reached at least their target epoch values.
//
// |current_epochs| is a sorted array of (axis, epoch) pairs representing the
// current progress of each axis in the system. Axes in |frontier| not found in
// |current_epochs| are treated as epoch 0 (unsatisfied unless target is 0).
//
// This is the core predicate for "can this operation execute now?":
// if (iree_async_frontier_is_satisfied(cmd->prerequisite, tracker_epochs, n))
// execute(cmd);
bool iree_async_frontier_is_satisfied(
const iree_async_frontier_t* frontier,
const iree_async_frontier_entry_t* current_epochs,
iree_host_size_t current_epochs_count);
// Finds entries in |target| that |reference| does not dominate. An entry is
// undominated if:
// - The axis exists in |target| but not in |reference|, OR
// - The axis exists in both but target.epoch > reference.epoch
//
// This is the combined tier 1 + tier 2 extraction for wait resolution: given
// a queue's frontier (reference) and a semaphore's frontier (target), the
// undominated entries are the axes where the queue has NOT yet waited on or
// submitted past the semaphore's causal dependencies. These axes need either
// device-side barriers (tier 2) or host-side deferral (tier 3).
//
// Writes undominated entries to |out_entries| (up to |capacity|). Returns the
// total count of undominated entries, which may exceed |capacity| to signal
// truncation. The output entries are in ascending axis order (preserved from
// the sorted merge-scan). Both input frontiers must have sorted entries.
//
// Pass |capacity| = 0 and |out_entries| = NULL to count without writing.
//
// Performance: same structure as iree_async_frontier_compare:
// - Fast path: identical axis sets → O(n) epoch comparison, no branching.
// - Slow path: different axis sets → O(n+m) merge-scan.
//
// Example:
// reference = {(A, 5), (B, 3)}
// target = {(A, 7), (C, 2)}
// result = {(A, 7), (C, 2)} count = 2
// (A is undominated: 7 > 5. C is undominated: absent from reference.)
uint8_t iree_async_frontier_find_undominated(
const iree_async_frontier_t* reference, const iree_async_frontier_t* target,
uint8_t capacity, iree_async_frontier_entry_t* out_entries);
#ifdef __cplusplus
} // extern "C"
#endif // __cplusplus
#endif // IREE_ASYNC_FRONTIER_H_