Skip to main content

ferrum_interfaces/vnext/resource/
program_binding.rs

1use super::{
2    invalid_resource, AllocationKind, AllocationLifetime, Arc, BTreeMap, BufferUsage,
3    DynamicPoolDomainSpec, DynamicStorageView, ElementType, InvocationLivenessMode,
4    LaneStableArenaSlotIdentity, LogicalBackingSliceAuthority, NodeId,
5    PhysicalBackingClaimIdentity, PlanHash, PlanNode, ResourceId, Serialize,
6    SubmissionWaveDomainCapacityLayout, SubmissionWaveDomainLayout, VNextError,
7};
8use crate::vnext::ReusableExecutionBucketId;
9use sha2::{Digest, Sha256};
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
12pub struct ProgramBindingSlot {
13    node_index: usize,
14    node_id: NodeId,
15    resource_id: ResourceId,
16    physical_offset_bytes: u64,
17    capacity_size_bytes: u64,
18    alignment_bytes: u64,
19}
20
21impl ProgramBindingSlot {
22    pub const fn node_index(&self) -> usize {
23        self.node_index
24    }
25
26    pub fn node_id(&self) -> &NodeId {
27        &self.node_id
28    }
29
30    pub fn resource_id(&self) -> &ResourceId {
31        &self.resource_id
32    }
33
34    pub const fn physical_offset_bytes(&self) -> u64 {
35        self.physical_offset_bytes
36    }
37
38    pub const fn capacity_size_bytes(&self) -> u64 {
39        self.capacity_size_bytes
40    }
41
42    pub const fn alignment_bytes(&self) -> u64 {
43        self.alignment_bytes
44    }
45}
46
47/// Cold-compiled binding arena layout for one immutable reusable-execution
48/// bucket. Every slot is a fixed, non-overlapping projection into one
49/// contiguous lane-stable physical claim.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
51pub struct ProgramBindingLayout {
52    reusable_execution_bucket_id: ReusableExecutionBucketId,
53    claim_identity: PhysicalBackingClaimIdentity,
54    physical_size_bytes: u64,
55    slots: Vec<ProgramBindingSlot>,
56    fingerprint: String,
57}
58
59impl ProgramBindingLayout {
60    pub fn reusable_execution_bucket_id(&self) -> &ReusableExecutionBucketId {
61        &self.reusable_execution_bucket_id
62    }
63
64    pub fn claim_identity(&self) -> &PhysicalBackingClaimIdentity {
65        &self.claim_identity
66    }
67
68    pub const fn physical_size_bytes(&self) -> u64 {
69        self.physical_size_bytes
70    }
71
72    pub fn slots(&self) -> &[ProgramBindingSlot] {
73        &self.slots
74    }
75
76    pub fn fingerprint(&self) -> &str {
77        &self.fingerprint
78    }
79
80    pub fn slot_for_node(&self, node_index: usize) -> Option<&ProgramBindingSlot> {
81        self.slots
82            .binary_search_by_key(&node_index, ProgramBindingSlot::node_index)
83            .ok()
84            .and_then(|index| self.slots.get(index))
85    }
86}
87
88/// One exact plan/lane binding of a cold-compiled layout to a live reusable
89/// arena slot. The wave owns this authority through its terminal fence; node
90/// commands retain only an `Arc` plus their immutable plan index.
91#[derive(Debug)]
92pub struct ProgramBindingExecutionBinding {
93    plan_hash: PlanHash,
94    layout: Arc<ProgramBindingLayout>,
95    lane_slot_identity: LaneStableArenaSlotIdentity,
96}
97
98impl ProgramBindingExecutionBinding {
99    pub(super) fn bind(
100        plan_hash: PlanHash,
101        node_count: usize,
102        layout: Arc<ProgramBindingLayout>,
103        lane_slot_identity: LaneStableArenaSlotIdentity,
104        backing_slices: &[LogicalBackingSliceAuthority],
105    ) -> Result<Arc<Self>, VNextError> {
106        if node_count == 0
107            || layout.slots().is_empty()
108            || layout
109                .slots()
110                .iter()
111                .any(|slot| slot.node_index() >= node_count)
112            || lane_slot_identity.lifetime() != AllocationLifetime::Invocation
113            || layout.reusable_execution_bucket_id()
114                != lane_slot_identity.reusable_execution_bucket_id()
115        {
116            return Err(invalid_resource(
117                "program binding layout differs from its plan or lane slot",
118            ));
119        }
120        for slot in layout.slots() {
121            let evidence = backing_slices
122                .binary_search_by(|slice| slice.resource_id().cmp(slot.resource_id()))
123                .ok()
124                .and_then(|index| backing_slices.get(index))
125                .map(LogicalBackingSliceAuthority::evidence)
126                .ok_or_else(|| {
127                    invalid_resource(
128                        "program binding slot has no claimed logical backing projection",
129                    )
130                })?;
131            if evidence.physical_claim_identity() != layout.claim_identity()
132                || evidence.reusable_execution_bucket_id()
133                    != Some(layout.reusable_execution_bucket_id())
134                || evidence.physical_offset_bytes() != slot.physical_offset_bytes()
135                || evidence.capacity_size_bytes() != slot.capacity_size_bytes()
136                || evidence.physical_size_bytes() != layout.physical_size_bytes()
137                || evidence.alignment_bytes() != slot.alignment_bytes()
138                || evidence.usage() != BufferUsage::Binding
139                || evidence.element_type() != ElementType::U8
140            {
141                return Err(invalid_resource(
142                    "program binding slot differs from its claimed physical projection",
143                ));
144            }
145        }
146        Ok(Arc::new(Self {
147            plan_hash,
148            layout,
149            lane_slot_identity,
150        }))
151    }
152
153    pub fn plan_hash(&self) -> &PlanHash {
154        &self.plan_hash
155    }
156
157    pub fn layout(&self) -> &ProgramBindingLayout {
158        &self.layout
159    }
160
161    pub fn lane_slot_identity(&self) -> &LaneStableArenaSlotIdentity {
162        &self.lane_slot_identity
163    }
164
165    pub(super) fn node(self: &Arc<Self>, node_index: usize) -> Option<ProgramBindingNodeBinding> {
166        self.layout
167            .slot_for_node(node_index)
168            .map(|_| ProgramBindingNodeBinding {
169                execution: Arc::clone(self),
170                node_index,
171            })
172    }
173}
174
175/// Backend-visible authority for exactly one provider-owned binding slot.
176/// Cloning this handle is allocation-free and cannot change the selected slot.
177#[derive(Debug, Clone)]
178pub struct ProgramBindingNodeBinding {
179    execution: Arc<ProgramBindingExecutionBinding>,
180    node_index: usize,
181}
182
183impl ProgramBindingNodeBinding {
184    pub const fn node_index(&self) -> usize {
185        self.node_index
186    }
187
188    pub fn plan_hash(&self) -> &PlanHash {
189        self.execution.plan_hash()
190    }
191
192    pub fn layout(&self) -> &ProgramBindingLayout {
193        self.execution.layout()
194    }
195
196    pub fn slot(&self) -> &ProgramBindingSlot {
197        self.execution
198            .layout()
199            .slot_for_node(self.node_index)
200            .expect("program binding node handle is constructed from one compiled slot")
201    }
202
203    pub fn lane_slot_identity(&self) -> &LaneStableArenaSlotIdentity {
204        self.execution.lane_slot_identity()
205    }
206}
207
208pub(super) fn compile_program_binding_layouts(
209    domains: &[DynamicPoolDomainSpec],
210    nodes: &[PlanNode],
211    layouts: &[Option<SubmissionWaveDomainLayout>],
212    reusable_capacity_layouts: &BTreeMap<
213        ReusableExecutionBucketId,
214        Vec<Option<SubmissionWaveDomainCapacityLayout>>,
215    >,
216) -> Result<BTreeMap<ReusableExecutionBucketId, ProgramBindingLayout>, VNextError> {
217    if domains.len() != layouts.len()
218        || reusable_capacity_layouts
219            .values()
220            .any(|capacities| capacities.len() != domains.len())
221    {
222        return Err(invalid_resource(
223            "program binding compiler received inconsistent domain layouts",
224        ));
225    }
226
227    let binding_domains = domains
228        .iter()
229        .enumerate()
230        .filter(|(_, domain)| {
231            domain
232                .descriptors
233                .iter()
234                .any(|descriptor| matches!(descriptor.kind(), AllocationKind::Binding { .. }))
235        })
236        .collect::<Vec<_>>();
237    if binding_domains.is_empty() {
238        return Ok(BTreeMap::new());
239    }
240    let [(domain_index, domain)] = binding_domains.as_slice() else {
241        return Err(invalid_resource(
242            "one compiled program cannot span multiple binding domains",
243        ));
244    };
245    if domain.pool.compatibility().usage() != BufferUsage::Binding
246        || domain.pool.compatibility().element_type() != ElementType::U8
247        || domain.pool.compatibility().profile().view() != DynamicStorageView::Contiguous
248        || domain.pool.invocation_liveness_mode() != InvocationLivenessMode::ConservativeConcurrent
249        || domain.descriptors.iter().any(|descriptor| {
250            descriptor.lifetime() != AllocationLifetime::Invocation
251                || descriptor.usage() != BufferUsage::Binding
252                || descriptor.element_type() != ElementType::U8
253                || !matches!(descriptor.kind(), AllocationKind::Binding { .. })
254        })
255    {
256        return Err(invalid_resource(
257            "program bindings require one contiguous conservative U8 binding domain",
258        ));
259    }
260    let base_layout = layouts
261        .get(*domain_index)
262        .and_then(Option::as_ref)
263        .ok_or_else(|| invalid_resource("program binding domain has no submission-wave layout"))?;
264    if base_layout.projection_count != domain.descriptors.len()
265        || base_layout.claim_identity.resource_ids().len() != domain.descriptors.len()
266    {
267        return Err(invalid_resource(
268            "program binding domain layout does not cover every binding descriptor",
269        ));
270    }
271
272    reusable_capacity_layouts
273        .iter()
274        .map(|(bucket_id, capacity_layouts)| {
275            let capacity = capacity_layouts
276                .get(*domain_index)
277                .and_then(Option::as_ref)
278                .ok_or_else(|| {
279                    invalid_resource(
280                        "reusable program binding bucket has no physical capacity layout",
281                    )
282                })?;
283            if capacity.projections.len() != domain.descriptors.len()
284                || capacity.physical_size_bytes == 0
285            {
286                return Err(invalid_resource(
287                    "reusable program binding capacity layout is incomplete",
288                ));
289            }
290
291            let mut slots = domain
292                .descriptors
293                .iter()
294                .enumerate()
295                .map(|(projection_index, descriptor)| {
296                    let AllocationKind::Binding { node_id } = descriptor.kind() else {
297                        unreachable!("binding domain was validated above")
298                    };
299                    let node_index = nodes
300                        .iter()
301                        .position(|node| node.id() == node_id)
302                        .ok_or_else(|| {
303                            invalid_resource(
304                                "program binding descriptor references a missing plan node",
305                            )
306                        })?;
307                    let node = &nodes[node_index];
308                    if node.binding_resource() != Some(descriptor.base_resource_id())
309                        || !node.resources().contains(descriptor.base_resource_id())
310                    {
311                        return Err(invalid_resource(
312                            "program binding node does not own its binding descriptor",
313                        ));
314                    }
315                    let projection = &capacity.projections[projection_index];
316                    Ok(ProgramBindingSlot {
317                        node_index,
318                        node_id: node_id.clone(),
319                        resource_id: descriptor.base_resource_id().clone(),
320                        physical_offset_bytes: projection.physical_offset_bytes,
321                        capacity_size_bytes: projection.capacity_size_bytes,
322                        alignment_bytes: descriptor.alignment_bytes(),
323                    })
324                })
325                .collect::<Result<Vec<_>, VNextError>>()?;
326            slots.sort_by_key(ProgramBindingSlot::physical_offset_bytes);
327            let contiguous_end = slots.iter().try_fold(0_u64, |expected_offset, slot| {
328                if slot.physical_offset_bytes != expected_offset
329                    || slot.capacity_size_bytes == 0
330                    || slot.physical_offset_bytes % slot.alignment_bytes != 0
331                    || slot.capacity_size_bytes % slot.alignment_bytes != 0
332                {
333                    return Err(invalid_resource(
334                        "program binding slots are empty, misaligned, or non-contiguous",
335                    ));
336                }
337                expected_offset
338                    .checked_add(slot.capacity_size_bytes)
339                    .ok_or_else(|| invalid_resource("program binding arena size overflows u64"))
340            })?;
341            if contiguous_end != capacity.physical_size_bytes {
342                return Err(invalid_resource(
343                    "program binding slots do not cover their physical arena exactly",
344                ));
345            }
346            slots.sort_by_key(ProgramBindingSlot::node_index);
347            if slots
348                .windows(2)
349                .any(|pair| pair[0].node_index >= pair[1].node_index)
350            {
351                return Err(invalid_resource(
352                    "program binding slots do not have unique plan-node owners",
353                ));
354            }
355
356            #[derive(Serialize)]
357            struct FingerprintMaterial<'a> {
358                domain: &'static str,
359                reusable_execution_bucket_id: &'a ReusableExecutionBucketId,
360                claim_identity: &'a PhysicalBackingClaimIdentity,
361                physical_size_bytes: u64,
362                slots: &'a [ProgramBindingSlot],
363            }
364            let bytes = serde_json::to_vec(&FingerprintMaterial {
365                domain: "ferrum.runtime-vnext.program-binding-layout.v1",
366                reusable_execution_bucket_id: bucket_id,
367                claim_identity: &base_layout.claim_identity,
368                physical_size_bytes: capacity.physical_size_bytes,
369                slots: &slots,
370            })
371            .map_err(|error| {
372                invalid_resource(format!(
373                    "program binding layout fingerprint encode failed: {error}"
374                ))
375            })?;
376            let layout = ProgramBindingLayout {
377                reusable_execution_bucket_id: bucket_id.clone(),
378                claim_identity: base_layout.claim_identity.clone(),
379                physical_size_bytes: capacity.physical_size_bytes,
380                slots,
381                fingerprint: format!("sha256/{:x}", Sha256::digest(bytes)),
382            };
383            Ok((bucket_id.clone(), layout))
384        })
385        .collect()
386}
387
388#[cfg(test)]
389mod tests {
390    use super::{
391        compile_program_binding_layouts, DynamicPoolDomainSpec, SubmissionWaveDomainCapacityLayout,
392        SubmissionWaveDomainLayout,
393    };
394    use crate::vnext::{
395        DynamicResourceDemand, DynamicResourceDescriptor, DynamicStorageAllocator,
396        DynamicStorageContract, DynamicStorageProfile, DynamicStorageView, MemoryPlan, NodeId,
397        PlanNode, ResolvedReusableExecutionBucket, ResourceId, ReusableExecutionBucketId,
398        ReusableExecutionBucketSpec, ReusableExecutionCapacity, ReusableExecutionClassId,
399        ReusableExecutionMemoryPlan, ReusablePoolWorkspaceBudget,
400    };
401    use std::collections::BTreeMap;
402
403    fn binding_descriptor(
404        resource_id: &str,
405        node_id: &str,
406        bytes: u64,
407        storage: DynamicStorageContract,
408    ) -> DynamicResourceDescriptor {
409        DynamicResourceDescriptor::resource_test_binding(
410            ResourceId::new(resource_id).expect("valid resource id"),
411            DynamicResourceDemand::fixed(bytes).expect("valid fixed binding demand"),
412            16,
413            NodeId::new(node_id).expect("valid node id"),
414            storage,
415            8,
416        )
417        .expect("valid binding descriptor")
418    }
419
420    fn compile_two_node_layout() -> (
421        Vec<PlanNode>,
422        Vec<DynamicPoolDomainSpec>,
423        Vec<Option<SubmissionWaveDomainLayout>>,
424        BTreeMap<ReusableExecutionBucketId, Vec<Option<SubmissionWaveDomainCapacityLayout>>>,
425        ReusableExecutionBucketId,
426    ) {
427        let storage = DynamicStorageContract::resource_test_contract(
428            DynamicStorageProfile::new(
429                DynamicStorageAllocator::LinearArena,
430                DynamicStorageView::Contiguous,
431            )
432            .expect("valid binding storage profile"),
433            "a".repeat(64),
434        )
435        .expect("valid binding storage");
436        let descriptors = vec![
437            binding_descriptor(
438                "resource/program-binding-first",
439                "node/program-binding-first",
440                64,
441                storage.clone(),
442            ),
443            binding_descriptor(
444                "resource/program-binding-second",
445                "node/program-binding-second",
446                128,
447                storage,
448            ),
449        ];
450        let nodes = vec![
451            PlanNode::resource_test_node_with_binding(
452                NodeId::new("node/program-binding-first").unwrap(),
453                descriptors[0].base_resource_id().clone(),
454            ),
455            PlanNode::resource_test_node_with_binding(
456                NodeId::new("node/program-binding-second").unwrap(),
457                descriptors[1].base_resource_id().clone(),
458            ),
459        ];
460        let pools =
461            MemoryPlan::derive_dynamic_pools(&descriptors, &nodes, 1 << 20).expect("derive pools");
462        let (_, domains) =
463            super::super::plan_dynamic_pool_admission(1, &pools, &descriptors).unwrap();
464        let layouts = domains
465            .iter()
466            .map(|domain| {
467                super::super::dynamic_pool::compile_submission_wave_domain_layout(domain, &nodes)
468            })
469            .collect::<Result<Vec<_>, _>>()
470            .unwrap();
471        let bucket = ReusableExecutionBucketSpec::new(
472            ReusableExecutionClassId::new("test.program-binding").unwrap(),
473            ReusableExecutionCapacity::new(1, 1, 1).unwrap(),
474        )
475        .unwrap();
476        let bucket_id = bucket.bucket_id().clone();
477        let reusable = ReusableExecutionMemoryPlan::new(
478            1,
479            1,
480            vec![ResolvedReusableExecutionBucket::new(
481                bucket,
482                vec![
483                    ReusablePoolWorkspaceBudget::new(descriptors[0].pool_id().clone(), 0, 192)
484                        .unwrap(),
485                ],
486            )
487            .unwrap()],
488        )
489        .unwrap();
490        let capacity_layouts =
491            super::super::dynamic_pool::compile_submission_wave_reusable_capacity_layouts(
492                &domains,
493                &layouts,
494                Some(&reusable),
495            )
496            .unwrap();
497        (nodes, domains, layouts, capacity_layouts, bucket_id)
498    }
499
500    #[test]
501    fn binding_layout_cold_compiles_one_exact_stable_arena() {
502        let (nodes, domains, layouts, capacity_layouts, bucket_id) = compile_two_node_layout();
503        let compiled =
504            compile_program_binding_layouts(&domains, &nodes, &layouts, &capacity_layouts)
505                .expect("compile binding layout");
506        let layout = compiled.get(&bucket_id).expect("compiled bucket layout");
507
508        assert_eq!(layout.physical_size_bytes(), 192);
509        assert_eq!(layout.slots().len(), 2);
510        assert_eq!(layout.slot_for_node(0).unwrap().physical_offset_bytes(), 0);
511        assert_eq!(layout.slot_for_node(0).unwrap().capacity_size_bytes(), 64);
512        assert_eq!(layout.slot_for_node(1).unwrap().physical_offset_bytes(), 64);
513        assert_eq!(layout.slot_for_node(1).unwrap().capacity_size_bytes(), 128);
514        assert!(layout.fingerprint().starts_with("sha256/"));
515        assert_eq!(
516            compiled,
517            compile_program_binding_layouts(&domains, &nodes, &layouts, &capacity_layouts)
518                .expect("recompile stable binding layout")
519        );
520    }
521
522    #[test]
523    fn binding_layout_rejects_a_node_that_does_not_own_its_binding() {
524        let (_, domains, layouts, capacity_layouts, _) = compile_two_node_layout();
525        let nodes = vec![
526            PlanNode::resource_test_node(NodeId::new("node/program-binding-first").unwrap()),
527            PlanNode::resource_test_node(NodeId::new("node/program-binding-second").unwrap()),
528        ];
529
530        let error = compile_program_binding_layouts(&domains, &nodes, &layouts, &capacity_layouts)
531            .expect_err("binding layout must reject missing node ownership");
532        assert!(error
533            .to_string()
534            .contains("does not own its binding descriptor"));
535    }
536}