src/jit/trace_dag.h
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #pragma once | ||
| 2 | |||
| 3 | #include <brotensor/tensor.h> | ||
| 4 | #include <cstdint> | ||
| 5 | #include <cstddef> | ||
| 6 | #include <vector> | ||
| 7 | #include <unordered_map> | ||
| 8 | #include <string> | ||
| 9 | |||
| 10 | namespace brotensor::jit { | ||
| 11 | |||
| 12 | enum class TraceOpKind : uint8_t { | ||
| 13 | Input = 0, | ||
| 14 | Add, | ||
| 15 | Sub, | ||
| 16 | Mul, | ||
| 17 | Div, | ||
| 18 | AddScalar, | ||
| 19 | SubScalar, | ||
| 20 | MulScalar, | ||
| 21 | DivScalar, | ||
| 22 | ScalarSub, | ||
| 23 | ScalarDiv, | ||
| 24 | FMA, | ||
| 25 | SiLU, | ||
| 26 | GELU, | ||
| 27 | ReLU, | ||
| 28 | RMSNorm, | ||
| 29 | LayerNorm, | ||
| 30 | Modulate, | ||
| 31 | Tanh, | ||
| 32 | Sigmoid, | ||
| 33 | Copy, | ||
| 34 | Where, | ||
| 35 | Clamp, | ||
| 36 | Cast | ||
| 37 | }; | ||
| 38 | |||
| 39 | const char* op_kind_name(TraceOpKind op); | ||
| 40 | |||
| 41 | // How a node's buffer maps onto the trace's dominant (rows, cols) shape. | ||
| 42 | // | ||
| 43 | // Row, Col and Scalar are the broadcast forms: a (1, D) row, a (R, 1) column, | ||
| 44 | // or a (1, 1) scalar combined against an (R, D) activation. The emitters turn | ||
| 45 | // these into a different address computation for that operand, not into a | ||
| 46 | // materialised expansion — a broadcast operand contributes D, R, or 1 elements | ||
| 47 | // of traffic, not R*D. | ||
| 48 | enum class BroadcastKind : uint8_t { Full = 0, Row = 1, Scalar = 2, Col = 3 }; | ||
| 49 | |||
| 50 | BroadcastKind broadcast_of(int node_rows, int node_cols, int rows, int cols); | ||
| 51 | |||
| 52 | 230 | struct TraceNode { | |
| 53 | 112 | int id = -1; | |
| 54 | 112 | TraceOpKind op = TraceOpKind::Input; | |
| 55 | std::vector<int> inputs; | ||
| 56 | std::vector<int> outputs; // consumer node indices | ||
| 57 | 112 | float scalar = 0.0f; | |
| 58 | 112 | float scalar2 = 0.0f; | |
| 59 | // Destination or input data pointer — and null for every node the fused | ||
| 60 | // kernel evaluates in registers and never spills. | ||
| 61 | // | ||
| 62 | // A buffer is present on exactly three kinds of node: an Input and an | ||
| 63 | // in-place target, whose storage the caller owns and whose address is | ||
| 64 | // therefore stable for the whole trace; a store() destination, likewise | ||
| 65 | // caller-owned; and a live-at-end intermediate, which end_trace() | ||
| 66 | // allocates and installs into the Tensor the caller is still holding. | ||
| 67 | // Every other node — every dead intermediate — carries a null buffer and | ||
| 68 | // is addressed by `id` alone, which is the reason the tracer no longer | ||
| 69 | // allocates a full-size tensor per traced op. Nothing hashes the buffer, | ||
| 70 | // so a null one cannot collide two different traces. | ||
| 71 | 112 | void* buffer = nullptr; | |
| 72 | 112 | Device device = Device::CPU; | |
| 73 | 112 | Dtype dtype = Dtype::FP32; | |
| 74 | 112 | int rows = 0; | |
| 75 | 112 | int cols = 0; | |
| 76 | 112 | bool is_inplace = false; | |
| 77 | 112 | int inplace_target_slot = -1; | |
| 78 | 112 | bool is_live_at_end = false; | |
| 79 | }; | ||
| 80 | |||
| 81 | class TraceDAG { | ||
| 82 | public: | ||
| 83 | 18 | TraceDAG() = default; | |
| 84 | |||
| 85 | int register_input(const Tensor& t); | ||
| 86 | int add_node(TraceOpKind op, const std::vector<int>& inputs, float scalar = 0.0f, | ||
| 87 | void* buffer = nullptr, const Device& dev = Device::CPU, | ||
| 88 | Dtype dt = Dtype::FP32, int rows = 0, int cols = 0, | ||
| 89 | float scalar2 = 0.0f); | ||
| 90 | int add_inplace_node(TraceOpKind op, int target_slot, int arg_slot, | ||
| 91 | void* buffer, float scalar = 0.0f, float scalar2 = 0.0f); | ||
| 92 | |||
| 93 | void analyze_liveness(); | ||
| 94 | uint64_t compute_hash() const; | ||
| 95 | |||
| 96 | // The trace's dominant shape: the largest (rows * cols) any node carries. | ||
| 97 | // Every non-broadcast node must match it; (1, cols), (rows, 1), and (1, 1) | ||
| 98 | // nodes are broadcast operands. | ||
| 99 | void dominant_shape(int& rows, int& cols) const; | ||
| 100 | |||
| 101 | 73 | const std::vector<TraceNode>& nodes() const noexcept { return nodes_; } | |
| 102 | 32 | std::vector<TraceNode>& nodes() noexcept { return nodes_; } | |
| 103 | 16 | const TraceNode& node(int id) const { return nodes_.at(id); } | |
| 104 | 11 | size_t node_count() const noexcept { return nodes_.size(); } | |
| 105 | |||
| 106 | // True when `slot` still describes `t` (same buffer, shape, dtype, device). | ||
| 107 | bool slot_matches(int slot, const Tensor& t) const; | ||
| 108 | void reset(); | ||
| 109 | |||
| 110 | private: | ||
| 111 | std::vector<TraceNode> nodes_; | ||
| 112 | std::unordered_map<const void*, int> input_ptr_to_slot_; | ||
| 113 | }; | ||
| 114 | |||
| 115 | // ─── TraceContext ─────────────────────────────────────────────────────────── | ||
| 116 | // | ||
| 117 | // The thread's trace in progress, and the symbolic tensors it has handed out. | ||
| 118 | // | ||
| 119 | // A traced op does not produce a tensor; it produces a *name* for a node of | ||
| 120 | // the DAG. record_op() returns that name as a Tensor with a null `data`, a | ||
| 121 | // real shape/dtype/device and `jit_slot` set — so the expression the caller | ||
| 122 | // wrote goes on type-checking and composing exactly as before while allocating | ||
| 123 | // nothing. Node identity follows suit: a symbolic operand is identified by its | ||
| 124 | // slot, and only a caller-owned buffer (an input, an in-place target, a store() | ||
| 125 | // destination) is still identified by its pointer, which is safe precisely | ||
| 126 | // because the caller owns it and it cannot be recycled mid-trace. | ||
| 127 | // | ||
| 128 | // The catch a tracked-pointer table solves: a value the caller still holds | ||
| 129 | // when the trace closes has to end up owning a real buffer it can read from. | ||
| 130 | // Every symbolic Tensor handed out is recorded here as slot -> Tensor*, and | ||
| 131 | // Tensor's move ctor / move assignment / destructor keep that table pointing | ||
| 132 | // at wherever the caller moved the value (see detail::jit_slot_retarget in | ||
| 133 | // tensor.h). end() then allocates for exactly those slots. | ||
| 134 | class TraceContext { | ||
| 135 | public: | ||
| 136 | static TraceContext& current(); | ||
| 137 | |||
| 138 | void begin(); | ||
| 139 | // Runs liveness, gives every still-held live-at-end intermediate a real | ||
| 140 | // buffer, and returns the DAG. Any symbolic Tensor the caller still holds | ||
| 141 | // whose value the fused kernel only consumes internally is neutralised to | ||
| 142 | // an empty tensor — its contents were never computed to memory. | ||
| 143 | TraceDAG end(); | ||
| 144 | // Throws away a trace in progress and neutralises every symbolic Tensor | ||
| 145 | // the caller still holds to an empty tensor. Leaves the context idle. | ||
| 146 | void discard(); | ||
| 147 | 39 | bool is_active() const noexcept { return active_; } | |
| 148 | |||
| 149 | int get_or_register_slot(const Tensor& t); | ||
| 150 | |||
| 151 | // Records `op` and returns the symbolic Tensor standing for its result. | ||
| 152 | Tensor record_op(TraceOpKind op, const std::vector<int>& in_slots, float scalar, | ||
| 153 | const Device& dev, Dtype dt, int rows, int cols, | ||
| 154 | float scalar2 = 0.0f); | ||
| 155 | |||
| 156 | // Records a Copy into `dst`, a buffer the caller already owns. | ||
| 157 | void record_store(int src_slot, const Tensor& dst); | ||
| 158 | |||
| 159 | // Records a 3-arg cast(src, dst, out_dtype) into `dst`. | ||
| 160 | void record_cast_out(int src_slot, const Tensor& src, Tensor& dst, Dtype out_dtype); | ||
| 161 | |||
| 162 | int record_inplace_op(TraceOpKind op, int target_slot, int arg_slot, float scalar, | ||
| 163 | Tensor& target_tensor, float scalar2 = 0.0f); | ||
| 164 | |||
| 165 | // Tensor lifetime callback: the symbolic Tensor for `slot` moved from | ||
| 166 | // `from` to `to`, or was destroyed (`to` == nullptr). Never throws and | ||
| 167 | // never allocates — Tensor's move and destructor are noexcept. | ||
| 168 | void retarget(int slot, const Tensor* from, Tensor* to) noexcept; | ||
| 169 | |||
| 170 | private: | ||
| 171 | 3 | TraceContext() = default; | |
| 172 | |||
| 173 | // Allocates and installs the buffers for live-at-end nodes that do not | ||
| 174 | // already have one; demotes a live-at-end node nobody can observe. | ||
| 175 | void materialize_(); | ||
| 176 | // Turns every still-tracked symbolic Tensor into an ordinary empty one. | ||
| 177 | void release_tracked_() noexcept; | ||
| 178 | |||
| 179 | 1 | bool active_ = false; | |
| 180 | TraceDAG dag_; | ||
| 181 | std::unordered_map<const void*, int> active_ptr_to_slot_; | ||
| 182 | std::unordered_map<int, Tensor*> slot_to_tensor_; | ||
| 183 | }; | ||
| 184 | |||
| 185 | } // namespace brotensor::jit | ||
| 186 |