kernel_planning.bend

stelliferous@0.0.2.0 · 228 lines · 38 definitions

1# Structural cost of the 4x8 pipeline. Only guarded dimensions enter here.
2# Packing and GEMM run in each leaf; reordering and final gather are serial.
3import Base
4import stelliferous@0.0.2.0/kernel_cost.bend as Cost
5import stelliferous@0.0.2.0/kernel_window.bend as KernelWindow
6import stelliferous@0.0.2.0/math_activation.bend as Activations
7import stelliferous@0.0.2.0/kernel_pipeline.bend as Pipeline
8
9type Owners is Data:
10  Owners{input_capacity: U32,bias_capacity: U32}
11
12# Image and stencil extents that decide which input rows a fork copies.
13type Geometry is Data:
14  Geometry{channels: U32,height: U32,width: U32,kernel: U32,stride: U32,padding: U32,output_width: U32}
15
16type Work is Data:
17  Work{positions: U32,inner: U32,rows: U32,owners: Owners,geometry: Geometry,activation: Activations.Activation}
18
19# The input rows a partition node holds and the capacity of their owner.
20type InputRows is Data:
21  InputRows{first: U32,rows: U32,capacity: Nat}
22
23# Allocation and moved/initialized payload are distinct for a copied band.
24type ForkInput is Data:
25  ForkInput{bytes: Nat,transfer: Nat,left: InputRows}
26
27# critical_ns is the span (the costliest root-to-leaf path) and work_ns the sum
28# over every leaf and fork; together they bound time on a given worker count.
29type Movement is Data:
30  Movement{packed: Nat,leaves: Nat,copies: Nat,critical_ns: F32,work_ns: F32,minimum_leaf_ns: F32}
31
32type Plan is Data:
33  Plan{packed: Bool,split_depth: U32,workspace_bytes: Nat,estimated_ns: F32}
34
35def capacity_loop(remaining: Nat,+value: U32,+capacity: Nat,zero: Bool) -> Nat:
36  match remaining zero:
37    case 0n zero: capacity
38    case 1n+rest True{}: capacity
39    case 1n+rest False{}:
40      +next = U32.shr(value)
41      capacity_loop(rest,next,Nat.double(capacity),U32.is_zero(next))
42
43def capacity(+elements: U32) -> Nat:
44  +value = (U32.max(1,elements) - 1 : U32)
45  capacity_loop(32n,value,1n,U32.is_zero(value))
46
47def float_parts(parts: Nat & Nat) -> F32:
48  (high,low) = parts
49  (F32.from_nat(high) * 4294967296.0 + F32.from_nat(low) : F32)
50
51def payload_float(bytes: Nat) -> F32:
52  float_parts(Nat.divmod(bytes,Nat.double(2147483648n)))
53
54def weight_bytes(work: Work) -> Nat:
55  Work{positions,inner,rows,owners,geometry,activation} = work
56  Nat.mul(32n,capacity(((rows + 7) / 8 * inner : U32)))
57
58# The weight reorder writes complete eight-channel tiles, including zero lanes.
59def reorder_ns(+work: Work,config: Cost.Configuration) -> F32:
60  Work{positions,+inner,+rows,owners,geometry,activation} = work
61  Cost.Configuration{mac,byte_ns,operation_ns,grain,wake} = config
62  ((U32.to_f32(((rows + 7) / 8 * 8 : U32)) * U32.to_f32(inner) * 4.0 + payload_float(weight_bytes(work))) * byte_ns : F32)
63
64# Each fork clones the reordered weights and bias, and splits the input rows.
65def fork_bytes(+work: Work,input: Nat) -> Nat:
66  Work{positions,inner,rows,Owners{input_capacity,bias},geometry,activation} = work
67  Nat.add(weight_bytes(work),Nat.add(input,Nat.mul(4n,U32.to_nat(U32.max(2,bias)))))
68
69def clone_bytes(+window: InputRows) -> Nat:
70  InputRows{first,rows,+capacity} = window
71  Nat.mul(4n,Nat.max(2n,capacity))
72
73def band_rows(+band: KernelWindow.Band,+channels: U32,+width: U32) -> InputRows:
74  KernelWindow.Band{+first,+rows} = band
75  InputRows{first,rows,capacity((channels * rows * width : U32))}
76
77def band_bytes(+band: KernelWindow.Band,+channels: U32,+width: U32) -> Nat:
78  KernelWindow.Band{first,+rows} = band
79  Nat.mul(4n,Nat.max(2n,capacity((channels * rows * width : U32))))
80
81# copied initializes the new owner and copies every logical band value.
82# Array.clone instead copies the complete allocation, without initialization.
83def input_transfer(copy: Bool,band: KernelWindow.Band,window: InputRows,channels: U32,width: U32) -> Nat:
84  match copy band:
85    case False{} band: clone_bytes(window)
86    case True{} KernelWindow.Band{first,rows}:
87      +count = (channels * rows * width : U32)
88      Nat.mul(4n,Nat.add(U32.to_nat(count),capacity(count)))
89
90# As in the pipelines: a checked band over aligned rows is copied and the
91# second half keeps the parent; otherwise the parent owner is cloned.
92def input_bytes(copy: Bool,+band: KernelWindow.Band,+window: InputRows,+channels: U32,+width: U32) -> Nat:
93  match copy:
94    case False{}: clone_bytes(window)
95    case True{}: band_bytes(band,channels,width)
96
97def left_rows(copy: Bool,+band: KernelWindow.Band,+window: InputRows,+channels: U32,+width: U32) -> InputRows:
98  match copy:
99    case False{}: window
100    case True{}: band_rows(band,channels,width)
101
102def fork_window(+offset: U32,+half: U32,+window: InputRows,+geometry: Geometry) -> ForkInput:
103  InputRows{+first,+count,+capacity} = window
104  Geometry{+channels,+height,+width,+kernel,+stride,+padding,+output_width} = geometry
105  +band = KernelWindow.band(first,count,height,kernel,stride,padding,output_width,offset,half)
106  +copy = KernelWindow.copies_band(first,count,height,kernel,stride,padding,output_width,offset,half,band)
107  +parent = {InputRows{first,count,capacity} : InputRows}
108  ForkInput{input_bytes(copy,band,parent,channels,width),input_transfer(copy,band,parent,channels,width),left_rows(copy,band,parent,channels,width)}
109
110def fork_input(+offset: U32,+half: U32,+window: InputRows,+work: Work) -> ForkInput:
111  Work{positions,inner,rows,owners,+geometry,activation} = work
112  fork_window(offset,half,window,geometry)
113
114def forked_bytes(+fork: ForkInput) -> Nat:
115  ForkInput{+bytes,transfer,left} = fork
116  bytes
117
118def forked_transfer(+fork: ForkInput) -> Nat:
119  ForkInput{bytes,+transfer,left} = fork
120  transfer
121
122def forked_left(+fork: ForkInput) -> InputRows:
123  ForkInput{bytes,transfer,+left} = fork
124  left
125
126def root_rows(+work: Work) -> InputRows:
127  Work{positions,inner,rows,Owners{+input,bias},Geometry{channels,+height,width,kernel,stride,padding,output_width},activation} = work
128  InputRows{0,height,U32.to_nat(input)}
129
130# The activation pass reads and writes each logical output in place; the
131# identity skips the pass.
132def activation_ns(+activation: Activations.Activation,+config: Cost.Configuration) -> F32:
133  Bool.pick(F32,Activations.is_identity(activation),0.0,Cost.element_ns(4.0,Activations.operations(activation),config))
134
135def leaf_cost(span: U32,+count: U32,work: Work,+config: Cost.Configuration) -> Movement:
136  Work{positions,+inner,+rows,owners,geometry,activation} = work
137  Cost.Configuration{mac_ns,+byte_ns,operation_ns,grain,wake} = config
138  +span = span
139  +packed = Nat.mul(32n,capacity(U32.max(Pipeline.block_tiles(),inner)))
140  +output_capacity = capacity((rows * count : U32))
141  +output = Nat.mul(4n,Nat.max(2n,output_capacity))
142  # Padded lanes still execute every MAC, including absent rows of the last unit.
143  +core = (U32.to_f32(((rows + 3) / 4 * 4 : U32)) * U32.to_f32(span) * U32.to_f32(inner) * mac_ns : F32)
144  # Initializing each allocation and transferring logical data are separate work.
145  +packing = ((U32.to_f32(span) * U32.to_f32(inner) * 4.0 + payload_float(packed)) * byte_ns : F32)
146  +stores = ((U32.to_f32(rows) * U32.to_f32(count) * 4.0 + payload_float(Nat.mul(4n,output_capacity))) * byte_ns : F32)
147  +finish = (U32.to_f32(rows) * U32.to_f32(count) * activation_ns(activation,config) : F32)
148  +time = (core + packing + stores + finish : F32)
149  Movement{packed,output,0n,time,time,time}
150
151def merge(bytes: Nat,transfer: Nat,byte_ns: F32,left: Movement,right: Movement) -> Movement:
152  Movement{lp,lo,lc,lt,lw,lmin} = left
153  Movement{rp,ro,rc,rt,rw,rmin} = right
154  +copy = (payload_float(transfer) * byte_ns : F32)
155  Movement{Nat.add(lp,rp),Nat.add(lo,ro),Nat.add(bytes,Nat.add(lc,rc)),
156    (copy + F32.max(lt,rt) : F32),(copy + lw + rw : F32),F32.min(lmin,rmin)}
157
158def movement(depth: Nat,+offset: U32,+span: U32,+window: InputRows,+work: Work,+config: Cost.Configuration) -> Movement:
159  match depth:
160    case 0n:
161      Work{+positions,inner,rows,owners,geometry,activation} = work
162      leaf_cost(span,U32.min(span,(positions - offset : U32)),work,config)
163    case 1n+ +rest:
164      Cost.Configuration{mac,byte_ns,operation_ns,grain,wake} = config
165      +half = (span / 16 * 8 : U32)
166      +fork = fork_input(offset,half,window,work)
167      merge(fork_bytes(work,forked_bytes(fork)),fork_bytes(work,forked_transfer(fork)),byte_ns,movement(rest,offset,half,forked_left(fork),work,config),
168        movement(rest,(offset + half : U32),(span - half : U32),window,work,config))
169
170def workspace(+depth: U32,+work: Work,cost: Movement) -> Nat:
171  Work{+positions,inner,rows,owners,geometry,activation} = work
172  Movement{packed,leaves,copies,span,total,minimum} = cost
173  Nat.add(weight_bytes(work),Nat.add(packed,Nat.add(copies,
174    Bool.pick(Nat,U32.is_zero(depth),0n,leaves))))
175
176# Estimate the partition on workers by (work - span) / workers + span, Graham's
177# bound for greedy list scheduling. BendRT does not steal work: it deals forked
178# tasks across rings that workers claim each turn, so this is an estimate, not
179# a guarantee. One worker gives the total work, so a split only adds its
180# copies; more workers shorten the span but pay copies.
181def scheduled(+workers: U32,+span: F32,+total: F32) -> F32:
182  ((total - span) / U32.to_f32(workers) + span : F32)
183
184def estimate(+depth: U32,+work: Work,config: Cost.Configuration,cost: Movement,+workers: U32) -> F32:
185  Work{+positions,+inner,+rows,owners,geometry,activation} = work
186  Cost.Configuration{mac,+byte_ns,operation_ns,grain,wake} = config
187  Movement{packed,leaves,copies,+span,+total,minimum} = cost
188  +reorder = reorder_ns(work,config)
189  # A depth-zero blocked leaf already owns the exact logical output.
190  +gather = Bool.pick(F32,U32.is_zero(depth),0.0,
191    ((U32.to_f32(rows) * U32.to_f32(positions) * 4.0 + payload_float(Nat.mul(4n,capacity((rows * positions : U32))))) * byte_ns : F32))
192  (reorder + scheduled(workers,span,total) + gather : F32)
193
194def fits(budget: Maybe<&2,Nat>,bytes: Nat) -> Bool:
195  match budget:
196    case None{}: True{}
197    case Some{limit}: Nat.is_le(bytes,limit)
198
199def choose(fits: Bool,depth: U32,bytes: Nat,+time: F32,best: Plan) -> Plan:
200  Plan{+packed,old_depth,old_bytes,+old_time} = best
201  Bool.pick(Plan,fits && (Bool.not(packed) || F32.is_lt(time,old_time)),Plan{True{},depth,bytes,time},Plan{packed,old_depth,old_bytes,old_time})
202
203def candidate(+depth: U32,+work: Work,+config: Cost.Configuration,+budget: Maybe<&2,Nat>,+workers: U32,+cost: Movement,best: Plan) -> Plan:
204  Cost.Configuration{mac,byte_ns,operation_ns,grain,wake} = config
205  Movement{packed,leaves,copies,span,total,minimum} = cost
206  choose((U32.is_zero(depth) || F32.is_le(grain,minimum)) && fits(budget,workspace(depth,work,cost)),
207    depth,workspace(depth,work,cost),estimate(depth,work,config,cost,workers),best)
208
209def candidates(remaining: Nat,allowed: Bool,+depth: U32,+tasks: U32,+ceiling: U32,+workers: U32,+span: U32,+work: Work,+config: Cost.Configuration,
210  +budget: Maybe<&2,Nat>,best: Plan) -> Plan:
211  match remaining allowed:
212    case 0n allowed: best
213    case 1n+rest False{}: best
214    case 1n+rest True{}:
215      +next_tasks = (tasks * 2 : U32)
216      candidates(rest,U32.is_le(tasks,(ceiling / 2 : U32)) && U32.is_le(1,(span / 8 / next_tasks : U32)),
217        (depth + 1 : U32),next_tasks,ceiling,workers,span,work,config,budget,
218        candidate(depth,work,config,budget,workers,movement(U32.to_nat(depth),0,span,root_rows(work),work,config),best))
219
220# workers is the runtime's pool size. As in XNNPACK, several workers take up to
221# five tasks each, so idle workers pick up the remaining ones; one worker never
222# splits. Every leaf keeps at least one block and one grain of work, and the
223# scheduling bound must strictly improve.
224def select(+work: Work,workers: U32,+budget: Maybe<&2,Nat>,+config: Cost.Configuration) -> Plan:
225  Work{positions,inner,rows,owners,geometry,activation} = work
226  +workers = U32.max(1,U32.min(workers,858993459))
227  candidates(32n,True{},0,1,Bool.pick(U32,U32.is_eq(workers,1),1,(workers * 5 : U32)),workers,KernelWindow.padded(positions),work,config,budget,
228    Plan{False{},0,0n,0.0})