Skip to main content

optirs_tpu/
main_types.rs

1// TPU (Tensor Processing Unit) support with XLA compilation
2//
3// This module provides TPU acceleration for optimizers using XLA (Accelerated Linear Algebra)
4// compilation for maximum performance on Google Cloud TPUs and other XLA-compatible hardware.
5
6use optirs_core::Optimizer;
7use scirs2_core::error::ErrorContext;
8use scirs2_core::ndarray::{Array, ArrayBase, Data, Dimension, Ix1};
9use scirs2_core::numeric::Float;
10use std::collections::HashMap;
11
12use crate::error::{OptimError, Result};
13
14/// TPU configuration for optimization
15#[derive(Debug, Clone)]
16pub struct TPUConfig {
17    /// TPU version (v2, v3, v4, v5e)
18    pub tpu_version: TPUVersion,
19
20    /// Number of TPU cores
21    pub num_cores: usize,
22
23    /// Enable XLA compilation
24    pub enable_xla: bool,
25
26    /// XLA optimization level
27    pub xla_optimization_level: XLAOptimizationLevel,
28
29    /// Enable mixed precision on TPU
30    pub mixed_precision: bool,
31
32    /// Batch size per core
33    pub batch_size_per_core: usize,
34
35    /// Enable TPU pod coordination
36    pub enable_pod_coordination: bool,
37
38    /// Pod topology
39    pub pod_topology: PodTopology,
40
41    /// Memory optimization strategy
42    pub memory_optimization: TPUMemoryOptimization,
43
44    /// Enable gradient compression for TPU communication
45    pub gradient_compression: bool,
46
47    /// Prefetch depth for input pipeline
48    pub prefetch_depth: usize,
49
50    /// Enable experimental features
51    pub experimental_features: bool,
52}
53
54impl Default for TPUConfig {
55    fn default() -> Self {
56        Self {
57            tpu_version: TPUVersion::V4,
58            num_cores: 8,
59            enable_xla: true,
60            xla_optimization_level: XLAOptimizationLevel::Aggressive,
61            mixed_precision: true,
62            batch_size_per_core: 32,
63            enable_pod_coordination: false,
64            pod_topology: PodTopology::Single,
65            memory_optimization: TPUMemoryOptimization::Balanced,
66            gradient_compression: true,
67            prefetch_depth: 2,
68            experimental_features: false,
69        }
70    }
71}
72
73/// TPU versions with different capabilities
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum TPUVersion {
76    V2,
77    V3,
78    V4,
79    V5e,
80    V5p,
81}
82
83/// XLA optimization levels
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum XLAOptimizationLevel {
86    None,
87    Basic,
88    Standard,
89    Aggressive,
90    Experimental,
91}
92
93/// TPU pod topologies
94#[derive(Debug, Clone, Copy, Default)]
95pub enum PodTopology {
96    #[default]
97    Single, // Single TPU device
98    Pod2x2,   // 4 TPUs in 2x2 grid
99    Pod4x4,   // 16 TPUs in 4x4 grid
100    Pod8x8,   // 64 TPUs in 8x8 grid
101    Pod16x16, // 256 TPUs in 16x16 grid
102    Pod32x32, // 1024 TPUs in 32x32 grid
103}
104
105/// TPU memory optimization strategies
106#[derive(Debug, Clone, Copy)]
107pub enum TPUMemoryOptimization {
108    /// Optimize for memory usage
109    Memory,
110    /// Optimize for speed
111    Speed,
112    /// Balanced optimization
113    Balanced,
114    /// Custom optimization
115    Custom,
116}
117
118/// TPU-optimized optimizer wrapper
119pub struct TPUOptimizer<O, A>
120where
121    A: Float + scirs2_core::ndarray::ScalarOperand + std::fmt::Debug,
122    O: Optimizer<A, scirs2_core::ndarray::Ix1>,
123{
124    /// Base optimizer
125    base_optimizer: O,
126
127    /// TPU configuration
128    config: TPUConfig,
129
130    /// XLA computation graph
131    xla_graph: Option<XLAComputationGraph>,
132
133    /// TPU memory allocator
134    memory_allocator: TPUMemoryAllocator<A>,
135
136    /// Pod coordinator for multi-TPU setups
137    pod_coordinator: Option<TPUPodCoordinator>,
138
139    /// Performance profiler
140    profiler: TPUProfiler,
141
142    /// Current step count
143    step_count: usize,
144
145    /// Compiled computation cache
146    computation_cache: HashMap<String, CompiledComputation>,
147}
148
149/// XLA computation graph for optimizer operations.
150///
151/// # Why this is not [`crate::xla::frontend::XLAComputation`]
152///
153/// This is a deliberately private, two-operation graph (see [`XLAOperation`])
154/// serving exactly one caller: [`TPUOptimizer::compile_step`], which needs a
155/// cheap elementwise description of a parameter update to size and cost. The
156/// crate's real IR -- with the full operation set, an operand graph, an
157/// optimization pipeline and a reference executor -- is
158/// [`crate::xla::frontend::XLAComputation`], and it is what
159/// [`crate::tpu_backend::TPUBackend`] compiles and runs.
160///
161/// The two are not unified because `TPUOptimizer` never executes a graph: it
162/// delegates the actual parameter update to the wrapped
163/// [`optirs_core::Optimizer`] and uses this description only for the compile
164/// metrics it reports. Rebuilding `tpu_step` on the full IR would mean lowering
165/// every base optimizer's update into XLA operations -- a much larger change
166/// than the accounting this type exists for. Recorded here rather than left as
167/// an unexplained second graph type.
168#[derive(Debug)]
169struct XLAComputationGraph {
170    /// Graph nodes
171    nodes: Vec<XLANode>,
172
173    /// Computation builder
174    builder: XLAComputationBuilder,
175
176    /// Input placeholders
177    inputs: HashMap<String, XLAOperand>,
178
179    /// Output operations
180    outputs: Vec<XLAOperand>,
181
182    /// Graph optimization passes
183    optimization_passes: Vec<XLAOptimizationPass>,
184}
185
186/// XLA computation node
187#[derive(Debug, Clone)]
188struct XLANode {
189    /// Operation type
190    operation: XLAOperation,
191
192    /// Input operands
193    inputs: Vec<XLAOperand>,
194
195    /// Output shape
196    outputshape: XLAShape,
197
198    /// Node metadata
199    metadata: XLANodeMetadata,
200}
201
202/// XLA operations emitted by [`TPUOptimizer::build_optimizer_computation`].
203///
204/// This is deliberately just the elementwise vocabulary the optimizer update
205/// needs. The full XLA operation set -- matmul, convolution, reductions,
206/// activations, custom calls -- lives in [`crate::xla::frontend::OperationType`],
207/// which is the IR the real compiler pipeline consumes; carrying a second,
208/// never-constructed copy of it here only advertised operations this builder
209/// cannot emit.
210#[derive(Debug, Clone)]
211enum XLAOperation {
212    Add,
213    Multiply,
214}
215
216/// XLA operand reference
217#[derive(Debug, Clone, Copy)]
218struct XLAOperand {
219    id: usize,
220    shape: XLAShape,
221}
222
223/// XLA tensor shape
224#[derive(Debug, Clone, Copy)]
225pub struct XLAShape {
226    dimensions: [usize; 4], // Max 4D for simplicity
227    rank: usize,
228    element_type: XLAElementType,
229}
230
231/// XLA element types the optimizer graph can carry.
232///
233/// `TPUOptimizer` is generic over a floating-point element type and selects
234/// `BF16` when mixed precision is configured, `F32` otherwise; integer element
235/// types were never constructible here.
236#[derive(Debug, Clone, Copy)]
237enum XLAElementType {
238    F32,
239    BF16,
240}
241
242/// XLA computation builder
243#[derive(Debug)]
244struct XLAComputationBuilder {
245    /// Optimization level
246    optimization_level: XLAOptimizationLevel,
247
248    /// Target TPU configuration
249    target_config: TPUConfig,
250}
251
252/// XLA optimization passes
253#[derive(Debug, Clone)]
254enum XLAOptimizationPass {
255    ConstantFolding,
256    DeadCodeElimination,
257    OperatorFusion,
258    LayoutOptimization,
259    MemoryOptimization,
260    TensorCoreUtilization,
261}
262
263/// Node metadata for optimization
264#[derive(Debug, Clone)]
265struct XLANodeMetadata {
266    /// Estimated FLOPs
267    flops: u64,
268
269    /// Memory usage estimate
270    memory_bytes: usize,
271}
272
273/// Aggregate TPU memory accounting for a [`TPUOptimizer`].
274///
275/// This tracks totals only. The pool/free-list/block machinery that used to be
276/// declared here (`memory_pools`, `MemoryPool`, `MemoryBlock`,
277/// `PoolUsageStats`) was constructed empty and never read, and the real
278/// per-device pool allocator -- with free lists, fit strategies, coalescing
279/// garbage collection and honest out-of-memory errors -- lives in
280/// [`crate::tpu_backend::TPUMemoryManager`]. A second, inert copy of it here
281/// claimed an allocator this type does not have.
282#[derive(Debug)]
283struct TPUMemoryAllocator<A: Float> {
284    /// Total TPU memory (bytes)
285    total_memory: usize,
286
287    /// Allocated memory (bytes)
288    allocated_memory: usize,
289
290    /// Fragmentation statistics
291    fragmentation_stats: FragmentationStats,
292
293    /// Phantom data
294    _phantom: std::marker::PhantomData<A>,
295}
296
297/// Memory fragmentation statistics
298#[derive(Debug, Clone)]
299struct FragmentationStats {
300    /// External fragmentation ratio
301    external_fragmentation: f64,
302}
303
304/// Replica count for the data-parallel path in [`TPUOptimizer::execute_distributed`].
305///
306/// Only the replica count is tracked here. Per-core placement, communication
307/// patterns, barriers and load balancing used to be declared alongside it and
308/// were never read; the real implementations of all four live in
309/// [`crate::coordination::PodCoordinator`] (device/channel topology, load
310/// balancing, fault detection) and [`crate::synchronization`] (barriers and ring
311/// collectives), which is where a caller that needs them should go.
312#[derive(Debug)]
313struct TPUPodCoordinator {
314    /// Number of TPU cores
315    num_cores: usize,
316}
317
318/// TPU performance profiler
319#[derive(Debug)]
320struct TPUProfiler {
321    /// Execution timeline
322    timeline: Vec<ProfileEvent>,
323
324    /// XLA compilation metrics
325    compilation_metrics: CompilationMetrics,
326
327    /// TPU utilization metrics
328    utilization_metrics: UtilizationMetrics,
329}
330
331/// One event recorded by the profiler, readable via
332/// [`TPUOptimizer::profile_timeline`].
333#[derive(Debug, Clone)]
334pub struct ProfileEvent {
335    /// Event timestamp
336    pub timestamp: std::time::Instant,
337
338    /// Event type
339    pub event_type: ProfileEventType,
340
341    /// Core ID
342    pub core_id: usize,
343
344    /// Duration (microseconds)
345    pub duration_us: u64,
346
347    /// Metadata
348    pub metadata: HashMap<String, String>,
349}
350
351/// Profile event types.
352///
353/// Only the three kinds this optimizer genuinely emits are listed: it compiles,
354/// it computes, and on the data-parallel path it performs a collective. It never
355/// issues a standalone memory transfer or a standalone barrier, so no variant
356/// claims that it does.
357#[derive(Debug, Clone, PartialEq, Eq)]
358pub enum ProfileEventType {
359    Computation,
360    Communication,
361    Compilation,
362}
363
364/// XLA compilation metrics
365#[derive(Debug, Clone)]
366pub struct CompilationMetrics {
367    /// Compilation time (milliseconds)
368    pub compilation_time_ms: u64,
369
370    /// Number of optimizations applied
371    pub optimizations_applied: usize,
372
373    /// Generated code size (bytes)
374    pub code_size: usize,
375}
376
377/// TPU utilization metrics
378#[derive(Debug, Clone)]
379pub struct UtilizationMetrics {
380    /// Compute utilization (0.0 to 1.0)
381    pub compute_utilization: f64,
382
383    /// Memory bandwidth utilization
384    pub memory_bandwidth_utilization: f64,
385
386    /// Inter-core communication utilization
387    pub communication_utilization: f64,
388
389    /// Matrix unit utilization
390    pub matrix_unit_utilization: f64,
391
392    /// Vector unit utilization
393    pub vector_unit_utilization: f64,
394}
395
396/// Compiled XLA computation.
397///
398/// The input/output shape table that used to be carried alongside
399/// (`IOSpecification`) was computed on every compile and never read by anything;
400/// the shapes are already recoverable from the encoded program in `code`.
401#[derive(Debug)]
402struct CompiledComputation {
403    /// Compilation ID: a content hash of `code`
404    id: String,
405
406    /// Compiled code
407    code: Vec<u8>,
408
409    /// Performance characteristics
410    perf_characteristics: PerformanceCharacteristics,
411
412    /// Memory requirements
413    memory_requirements: MemoryRequirements,
414}
415
416/// Performance characteristics
417#[derive(Debug, Clone)]
418struct PerformanceCharacteristics {
419    /// Estimated execution time (microseconds)
420    estimated_execution_time_us: u64,
421
422    /// FLOPs count
423    flops: u64,
424
425    /// Memory bandwidth required (GB/s)
426    memory_bandwidth_gbs: f64,
427
428    /// TPU utilization estimate
429    utilization_estimate: f64,
430}
431
432/// Memory requirements
433#[derive(Debug, Clone)]
434struct MemoryRequirements {
435    /// Total memory needed (bytes)
436    total_memory: usize,
437
438    /// Working memory (bytes)
439    working_memory: usize,
440
441    /// Parameter memory (bytes)
442    parameter_memory: usize,
443
444    /// Temporary memory (bytes)
445    temp_memory: usize,
446}
447
448impl<O, A> TPUOptimizer<O, A>
449where
450    A: Float
451        + Default
452        + Clone
453        + Send
454        + Sync
455        + scirs2_core::ndarray::ScalarOperand
456        + std::fmt::Debug,
457    O: Optimizer<A, scirs2_core::ndarray::Ix1> + Send + Sync,
458{
459    /// Create a new TPU optimizer
460    pub fn new(base_optimizer: O, config: TPUConfig) -> Result<Self> {
461        let memory_allocator = TPUMemoryAllocator::new(&config)?;
462        let pod_coordinator = if config.enable_pod_coordination {
463            Some(TPUPodCoordinator::new(&config)?)
464        } else {
465            None
466        };
467
468        let profiler = TPUProfiler::new();
469
470        Ok(Self {
471            base_optimizer,
472            config,
473            xla_graph: None,
474            memory_allocator,
475            pod_coordinator,
476            profiler,
477            step_count: 0,
478            computation_cache: HashMap::new(),
479        })
480    }
481
482    /// Initialize XLA computation graph
483    pub fn initialize_xla_graph(&mut self) -> Result<()> {
484        if !self.config.enable_xla {
485            return Ok(());
486        }
487
488        self.xla_graph = Some(self.default_xla_graph());
489
490        Ok(())
491    }
492
493    /// Build a fresh, empty XLA computation graph configured from the current TPU config.
494    ///
495    /// This is the single source of truth for the default optimization-pass pipeline and
496    /// is reused both by [`Self::initialize_xla_graph`] and by
497    /// [`Self::build_optimizer_computation`] when no graph has been initialized yet, so a
498    /// `tpu_step` never fails merely because `initialize_xla_graph` was not called first.
499    fn default_xla_graph(&self) -> XLAComputationGraph {
500        let builder =
501            XLAComputationBuilder::new(self.config.xla_optimization_level, self.config.clone());
502
503        XLAComputationGraph {
504            nodes: Vec::new(),
505            builder,
506            inputs: HashMap::new(),
507            outputs: Vec::new(),
508            optimization_passes: vec![
509                XLAOptimizationPass::ConstantFolding,
510                XLAOptimizationPass::DeadCodeElimination,
511                XLAOptimizationPass::OperatorFusion,
512                XLAOptimizationPass::LayoutOptimization,
513                XLAOptimizationPass::MemoryOptimization,
514                XLAOptimizationPass::TensorCoreUtilization,
515            ],
516        }
517    }
518
519    /// Compile optimizer step for TPU execution
520    pub fn compile_step(&mut self, inputshapes: &[XLAShape]) -> Result<String> {
521        let compilation_id = format!("optimizer_step_{}", self.step_count);
522
523        if self.computation_cache.contains_key(&compilation_id) {
524            return Ok(compilation_id);
525        }
526
527        let start_time = std::time::Instant::now();
528
529        // Build XLA computation
530        let computation = self.build_optimizer_computation(inputshapes)?;
531
532        // Apply optimization passes. Only passes that actually transform the graph are
533        // reported in `optimizations_applied` (a no-op pass is not counted).
534        let (optimized_computation, effective_passes) =
535            self.apply_optimization_passes(computation)?;
536
537        // Compile to TPU code
538        let compiled = self.compile_to_tpu(optimized_computation)?;
539        let generated_code_size = compiled.code.len();
540
541        let compilation_time = start_time.elapsed();
542
543        // Update compilation metrics from the real compilation result.
544        self.profiler.compilation_metrics.compilation_time_ms = compilation_time.as_millis() as u64;
545        self.profiler.compilation_metrics.optimizations_applied = effective_passes;
546        self.profiler.compilation_metrics.code_size = generated_code_size;
547
548        // Update utilization from the compiled program's own derived
549        // characteristics. Previously these were reported as zeros forever even
550        // though `compile_to_tpu` had already computed them.
551        let peak_bandwidth_gbs = self.get_interconnect_bandwidth();
552        self.profiler.utilization_metrics.compute_utilization = compiled
553            .perf_characteristics
554            .utilization_estimate
555            .clamp(0.0, 1.0);
556        self.profiler
557            .utilization_metrics
558            .memory_bandwidth_utilization = if peak_bandwidth_gbs > 0.0 {
559            (compiled.perf_characteristics.memory_bandwidth_gbs / peak_bandwidth_gbs)
560                .clamp(0.0, 1.0)
561        } else {
562            0.0
563        };
564        // Communication only happens on the data-parallel path; a single-device
565        // configuration honestly reports none.
566        self.profiler.utilization_metrics.communication_utilization =
567            if self.pod_coordinator.is_some() {
568                self.profiler.utilization_metrics.compute_utilization
569            } else {
570                0.0
571            };
572        // The reference update is elementwise, so it runs on the vector units;
573        // no matrix-unit work is emitted, and claiming otherwise would be a
574        // fabricated number.
575        self.profiler.utilization_metrics.vector_unit_utilization =
576            self.profiler.utilization_metrics.compute_utilization;
577        self.profiler.utilization_metrics.matrix_unit_utilization = 0.0;
578
579        // Record the compilation itself as a real profiler event, tagged with
580        // the program's content hash and its derived memory footprint.
581        let mut metadata = HashMap::new();
582        metadata.insert("program".to_string(), compiled.id.clone());
583        metadata.insert(
584            "total_memory".to_string(),
585            compiled.memory_requirements.total_memory.to_string(),
586        );
587        metadata.insert(
588            "working_memory".to_string(),
589            compiled.memory_requirements.working_memory.to_string(),
590        );
591        metadata.insert(
592            "parameter_memory".to_string(),
593            compiled.memory_requirements.parameter_memory.to_string(),
594        );
595        metadata.insert(
596            "temp_memory".to_string(),
597            compiled.memory_requirements.temp_memory.to_string(),
598        );
599        metadata.insert(
600            "flops".to_string(),
601            compiled.perf_characteristics.flops.to_string(),
602        );
603        metadata.insert(
604            "estimated_execution_time_us".to_string(),
605            compiled
606                .perf_characteristics
607                .estimated_execution_time_us
608                .to_string(),
609        );
610        self.profiler.timeline.push(ProfileEvent {
611            timestamp: start_time,
612            event_type: ProfileEventType::Compilation,
613            core_id: 0,
614            duration_us: compilation_time.as_micros() as u64,
615            metadata,
616        });
617
618        // Cache compiled computation
619        self.computation_cache
620            .insert(compilation_id.clone(), compiled);
621
622        Ok(compilation_id)
623    }
624
625    /// Execute TPU-optimized step
626    pub fn tpu_step<S, DIM>(
627        &mut self,
628        params: &ArrayBase<S, DIM>,
629        gradients: &ArrayBase<S, DIM>,
630    ) -> Result<Array<A, DIM>>
631    where
632        S: Data<Elem = A>,
633        DIM: Dimension + Clone,
634    {
635        let start_time = std::time::Instant::now();
636
637        // Convert to XLA shapes
638        let paramshape = self.array_to_xlashape(params)?;
639        let gradshape = self.array_to_xlashape(gradients)?;
640
641        // Compile if needed
642        let computation_id = self.compile_step(&[paramshape, gradshape])?;
643
644        // Execute the optimizer step on the CPU reference backend. When a pod coordinator
645        // is configured we go through the data-parallel path (gradient reduction across the
646        // simulated device count); otherwise we run the single-device path.
647        let result = if self.pod_coordinator.is_some() {
648            self.execute_distributed(&computation_id, params, gradients)?
649        } else {
650            self.execute_single_tpu(&computation_id, params, gradients)?
651        };
652
653        // Update profiling
654        let execution_time = start_time.elapsed();
655        self.profiler.timeline.push(ProfileEvent {
656            timestamp: start_time,
657            event_type: ProfileEventType::Computation,
658            core_id: 0,
659            duration_us: execution_time.as_micros() as u64,
660            metadata: HashMap::new(),
661        });
662
663        self.step_count += 1;
664
665        Ok(result)
666    }
667
668    /// Build the computation graph for one optimizer step.
669    ///
670    /// The graph carries the real update as operations, not just placeholders:
671    /// `scaled = gradient * learning_rate` followed by `updated = parameter +
672    /// scaled` (the wrapped optimizer applies the sign; the graph describes the
673    /// elementwise shape of the work). Before this the node list was always
674    /// empty, so every compiled program encoded zero operations and
675    /// `compile_to_tpu` summed zero node FLOPs no matter how large the tensors
676    /// were.
677    fn build_optimizer_computation(&self, inputshapes: &[XLAShape]) -> Result<XLAComputationGraph> {
678        // Start from the initialized graph if present, otherwise from a fresh default graph
679        // derived from the current configuration (no panic when uninitialized).
680        let mut graph = match self.xla_graph.as_ref() {
681            Some(existing) => existing.clone(),
682            None => self.default_xla_graph(),
683        };
684
685        // Add input placeholders for the parameter/gradient tensors.
686        let mut operands = Vec::with_capacity(inputshapes.len());
687        for (i, &shape) in inputshapes.iter().enumerate() {
688            let operand = XLAOperand { id: i, shape };
689            graph.inputs.insert(format!("input_{}", i), operand);
690            operands.push(operand);
691        }
692
693        // The update is defined for the (parameter, gradient) pair; anything
694        // else is just a placeholder set with no operations to emit.
695        if let [parameter, gradient] = operands.as_slice() {
696            let elements = shape_element_count(&gradient.shape);
697            let bytes = shape_byte_count(&gradient.shape);
698            let next_id = graph.inputs.len();
699
700            // scaled = gradient * learning_rate
701            let scaled = XLAOperand {
702                id: next_id,
703                shape: gradient.shape,
704            };
705            graph.nodes.push(XLANode {
706                operation: XLAOperation::Multiply,
707                inputs: vec![*gradient],
708                outputshape: gradient.shape,
709                metadata: XLANodeMetadata {
710                    flops: elements,
711                    memory_bytes: bytes,
712                },
713            });
714
715            // updated = parameter + scaled
716            let updated = XLAOperand {
717                id: next_id + 1,
718                shape: parameter.shape,
719            };
720            graph.nodes.push(XLANode {
721                operation: XLAOperation::Add,
722                inputs: vec![*parameter, scaled],
723                outputshape: parameter.shape,
724                metadata: XLANodeMetadata {
725                    flops: shape_element_count(&parameter.shape),
726                    memory_bytes: shape_byte_count(&parameter.shape),
727                },
728            });
729
730            graph.outputs = vec![updated];
731        }
732
733        Ok(graph)
734    }
735
736    /// Run every optimization pass over the computation graph.
737    ///
738    /// Returns the transformed graph together with the number of passes that actually
739    /// changed the graph. Passes that leave the graph unchanged are *not* counted, so the
740    /// reported `optimizations_applied` reflects real work rather than the pipeline length.
741    fn apply_optimization_passes(
742        &self,
743        mut computation: XLAComputationGraph,
744    ) -> Result<(XLAComputationGraph, usize)> {
745        let mut effective = 0usize;
746        for pass in computation.optimization_passes.clone() {
747            let (next, changed) = self.apply_single_pass(computation, &pass)?;
748            computation = next;
749            if changed {
750                effective += 1;
751            }
752        }
753        Ok((computation, effective))
754    }
755
756    /// Apply a single optimization pass, returning the (possibly) transformed graph and a
757    /// flag indicating whether the pass modified the graph.
758    ///
759    /// `DeadCodeElimination` is implemented as a real transform: nodes that neither perform
760    /// any floating-point work nor touch any memory (estimated FLOPs and bytes both zero)
761    /// cannot influence the outputs and are removed. The remaining passes are structural
762    /// no-ops for the current elementwise-optimizer graph shape and therefore report
763    /// `changed == false` (so they do not inflate `optimizations_applied`).
764    fn apply_single_pass(
765        &self,
766        mut computation: XLAComputationGraph,
767        pass: &XLAOptimizationPass,
768    ) -> Result<(XLAComputationGraph, bool)> {
769        let changed = match pass {
770            XLAOptimizationPass::DeadCodeElimination => {
771                let before = computation.nodes.len();
772                computation
773                    .nodes
774                    .retain(|node| node.metadata.flops != 0 || node.metadata.memory_bytes != 0);
775                computation.nodes.len() != before
776            }
777            XLAOptimizationPass::ConstantFolding
778            | XLAOptimizationPass::OperatorFusion
779            | XLAOptimizationPass::LayoutOptimization
780            | XLAOptimizationPass::MemoryOptimization
781            | XLAOptimizationPass::TensorCoreUtilization => false,
782        };
783        Ok((computation, changed))
784    }
785
786    fn compile_to_tpu(&self, computation: XLAComputationGraph) -> Result<CompiledComputation> {
787        // Serialize the program to a real, deterministic byte encoding. The bytes are a
788        // stable function of the graph (magic header, inputs sorted by name, node list,
789        // outputs and the optimization-pass pipeline), so identical programs always compile
790        // to identical code and the compilation id is a content hash of that code.
791        let code = encode_program(&computation);
792        let compilation_id = format!("tpu_comp_{:016x}", fnv1a_64(&code));
793
794        // ---- Derive performance characteristics from the actual computation ----
795
796        // Total number of parameter/gradient elements the step reads.
797        let input_elements: u64 = computation
798            .inputs
799            .values()
800            .map(|op| shape_element_count(&op.shape))
801            .sum();
802
803        // FLOPs: the reference optimizer update is elementwise, costing a small constant
804        // number of floating-point ops per element (one multiply + one add for an
805        // SGD-style `p - lr * g`), plus any explicit per-node FLOPs recorded in the graph.
806        const FLOPS_PER_ELEMENT: u64 = 2;
807        let node_flops: u64 = computation
808            .nodes
809            .iter()
810            .map(|node| node.metadata.flops)
811            .sum();
812        let flops = input_elements
813            .saturating_mul(FLOPS_PER_ELEMENT)
814            .saturating_add(node_flops);
815
816        // Estimated execution time: FLOPs divided by the per-chip peak compute throughput
817        // of the target TPU version (published reference specs). Clamped to a 1us floor to
818        // account for unavoidable dispatch latency on any non-empty program.
819        let peak_flops_per_us = self.peak_compute_flops_per_us();
820        let estimated_execution_time_us =
821            flops.checked_div(peak_flops_per_us).unwrap_or(flops).max(1);
822
823        // Utilization: a saturating model where utilization approaches 1.0 as the tensor
824        // grows large enough to amortize pipeline-fill / dispatch overhead. `saturation` is
825        // the element count at which ~50% utilization is reached, derived from the
826        // configured per-core batch size and core count.
827        let saturation = (self
828            .config
829            .batch_size_per_core
830            .saturating_mul(self.config.num_cores))
831        .max(1) as f64;
832        let elems = input_elements as f64;
833        let utilization_estimate = elems / (elems + saturation);
834
835        // ---- Derive memory requirements from the actual tensor shapes ----
836        let input_bytes: usize = computation
837            .inputs
838            .values()
839            .map(|op| shape_byte_count(&op.shape))
840            .sum();
841        let output_bytes: usize = computation
842            .outputs
843            .iter()
844            .map(|op| shape_byte_count(&op.shape))
845            .sum();
846        let largest_input_bytes = computation
847            .inputs
848            .values()
849            .map(|op| shape_byte_count(&op.shape))
850            .max()
851            .unwrap_or(0);
852
853        let working_memory = input_bytes.saturating_add(output_bytes);
854        let parameter_memory = largest_input_bytes;
855        let temp_memory = working_memory;
856        let total_memory = working_memory
857            .saturating_add(parameter_memory)
858            .saturating_add(temp_memory);
859
860        // Memory bandwidth: working-set + parameter bytes moved over the estimated time.
861        let bytes_moved = working_memory.saturating_add(parameter_memory) as f64;
862        let seconds = estimated_execution_time_us as f64 / 1.0e6;
863        let memory_bandwidth_gbs = if seconds > 0.0 {
864            (bytes_moved / 1.0e9) / seconds
865        } else {
866            0.0
867        };
868
869        let perf_characteristics = PerformanceCharacteristics {
870            estimated_execution_time_us,
871            flops,
872            memory_bandwidth_gbs,
873            utilization_estimate,
874        };
875
876        let memory_requirements = MemoryRequirements {
877            total_memory,
878            working_memory,
879            parameter_memory,
880            temp_memory,
881        };
882
883        Ok(CompiledComputation {
884            id: compilation_id,
885            code,
886            perf_characteristics,
887            memory_requirements,
888        })
889    }
890
891    /// Per-chip peak compute throughput (FLOPs per microsecond) for the configured TPU
892    /// version, from published reference specifications. Used only to turn a real FLOP
893    /// count into a time estimate; it is never used as a standalone fabricated latency.
894    fn peak_compute_flops_per_us(&self) -> u64 {
895        match self.config.tpu_version {
896            TPUVersion::V2 => 45_000_000,   // ~45 TFLOP/s
897            TPUVersion::V3 => 123_000_000,  // ~123 TFLOP/s
898            TPUVersion::V4 => 275_000_000,  // ~275 TFLOP/s
899            TPUVersion::V5e => 197_000_000, // ~197 TFLOP/s
900            TPUVersion::V5p => 459_000_000, // ~459 TFLOP/s
901        }
902    }
903
904    /// Run the wrapped optimizer's parameter update on the CPU for arbitrary-rank tensors.
905    ///
906    /// The inner optimizer `O` operates on rank-1 (`Ix1`) tensors, so we flatten the
907    /// parameters and gradients to 1-D, delegate the real update to `O::step`, and reshape
908    /// the result back to the caller's original dimensionality. This is the honest,
909    /// hardware-free behavior of a "TPU step": the same math a TPU would perform, executed
910    /// on the CPU reference backend.
911    fn cpu_optimizer_update<S, DIM>(
912        &mut self,
913        params: &ArrayBase<S, DIM>,
914        gradients: &ArrayBase<S, DIM>,
915    ) -> Result<Array<A, DIM>>
916    where
917        S: Data<Elem = A>,
918        DIM: Dimension + Clone,
919    {
920        if params.shape() != gradients.shape() {
921            return Err(OptimError::ShapeError(ErrorContext::new(format!(
922                "parameter shape {:?} does not match gradient shape {:?}",
923                params.shape(),
924                gradients.shape()
925            ))));
926        }
927
928        let params_flat: Array<A, Ix1> = params.iter().cloned().collect();
929        let grads_flat: Array<A, Ix1> = gradients.iter().cloned().collect();
930
931        let updated_flat = self
932            .base_optimizer
933            .step(&params_flat, &grads_flat)
934            .map_err(|e| {
935                OptimError::ComputationError(ErrorContext::new(format!(
936                    "inner optimizer step failed: {e}"
937                )))
938            })?;
939
940        let updated_vec: Vec<A> = updated_flat.into_iter().collect();
941        Array::from_shape_vec(params.raw_dim(), updated_vec).map_err(|e| {
942            OptimError::ShapeError(ErrorContext::new(format!(
943                "failed to reshape updated parameters to original shape: {e}"
944            )))
945        })
946    }
947
948    fn execute_single_tpu<S, DIM>(
949        &mut self,
950        _computation_id: &str,
951        params: &ArrayBase<S, DIM>,
952        gradients: &ArrayBase<S, DIM>,
953    ) -> Result<Array<A, DIM>>
954    where
955        S: Data<Elem = A>,
956        DIM: Dimension + Clone,
957    {
958        // Single-device execution: run the wrapped optimizer's update on the CPU.
959        self.cpu_optimizer_update(params, gradients)
960    }
961
962    fn execute_distributed<S, DIM>(
963        &mut self,
964        _computation_id: &str,
965        params: &ArrayBase<S, DIM>,
966        gradients: &ArrayBase<S, DIM>,
967    ) -> Result<Array<A, DIM>>
968    where
969        S: Data<Elem = A>,
970        DIM: Dimension + Clone,
971    {
972        // Data-parallel execution across the simulated device count.
973        //
974        // In data parallelism each of the `num_cores` replicas computes a gradient on its
975        // shard of the batch and the replicas all-reduce (average) their gradients before
976        // the update. Here we are given a single gradient tensor that represents the
977        // synchronized global gradient (equivalently, `num_cores` identical replicas). The
978        // mean of identical replicas is the input gradient itself, so the averaged gradient
979        // equals `gradients` and the CPU reference performs exactly the same parameter
980        // update as the single-device path. We record the all-reduce as a communication
981        // event so the profiler reflects the collective, then apply the update.
982        let num_cores = self
983            .pod_coordinator
984            .as_ref()
985            .map(|coordinator| coordinator.num_cores)
986            .unwrap_or(1);
987
988        let comm_start = std::time::Instant::now();
989        let mut metadata = HashMap::new();
990        metadata.insert("collective".to_string(), "all_reduce_mean".to_string());
991        metadata.insert("replicas".to_string(), num_cores.to_string());
992        self.profiler.timeline.push(ProfileEvent {
993            timestamp: comm_start,
994            event_type: ProfileEventType::Communication,
995            core_id: 0,
996            duration_us: comm_start.elapsed().as_micros() as u64,
997            metadata,
998        });
999
1000        self.cpu_optimizer_update(params, gradients)
1001    }
1002
1003    fn array_to_xlashape<S, DIM>(&self, array: &ArrayBase<S, DIM>) -> Result<XLAShape>
1004    where
1005        S: Data<Elem = A>,
1006        DIM: Dimension,
1007    {
1008        let dims = array.shape();
1009        let mut dimensions = [1usize; 4];
1010
1011        for (i, &dim) in dims.iter().enumerate().take(4) {
1012            dimensions[i] = dim;
1013        }
1014
1015        Ok(XLAShape {
1016            dimensions,
1017            rank: dims.len().min(4),
1018            // Mixed precision means the graph carries bf16 tensors, which is
1019            // what the byte-size and encoding helpers key off; without this the
1020            // shape claimed f32 regardless of configuration.
1021            element_type: if self.config.mixed_precision {
1022                XLAElementType::BF16
1023            } else {
1024                XLAElementType::F32
1025            },
1026        })
1027    }
1028
1029    /// Get TPU performance metrics
1030    pub fn get_performance_metrics(&self) -> TPUPerformanceMetrics {
1031        TPUPerformanceMetrics {
1032            utilization: self.profiler.utilization_metrics.clone(),
1033            compilation: self.profiler.compilation_metrics.clone(),
1034            memory_usage: self.memory_allocator.get_usage_stats(),
1035            step_count: self.step_count,
1036            cache_hit_rate: self.get_cache_hit_rate(),
1037        }
1038    }
1039
1040    /// Events recorded by the profiler, oldest first.
1041    ///
1042    /// Compilations, computations and (on the data-parallel path) collectives
1043    /// are all recorded here with their real measured durations.
1044    pub fn profile_timeline(&self) -> &[ProfileEvent] {
1045        &self.profiler.timeline
1046    }
1047
1048    fn get_cache_hit_rate(&self) -> f64 {
1049        if self.step_count == 0 {
1050            0.0
1051        } else {
1052            self.computation_cache.len() as f64 / self.step_count as f64
1053        }
1054    }
1055
1056    /// Optimize TPU memory layout
1057    pub fn optimize_memory_layout(&mut self) -> Result<()> {
1058        self.memory_allocator.optimize_layout()?;
1059        Ok(())
1060    }
1061
1062    /// Get TPU topology information
1063    pub fn get_topology_info(&self) -> TPUTopologyInfo {
1064        TPUTopologyInfo {
1065            version: self.config.tpu_version,
1066            num_cores: self.config.num_cores,
1067            topology: self.config.pod_topology,
1068            memory_per_core: self.get_memory_per_core(),
1069            interconnect_bandwidth: self.get_interconnect_bandwidth(),
1070        }
1071    }
1072
1073    fn get_memory_per_core(&self) -> usize {
1074        match self.config.tpu_version {
1075            TPUVersion::V2 => 8 * 1024 * 1024 * 1024,   // 8GB
1076            TPUVersion::V3 => 16 * 1024 * 1024 * 1024,  // 16GB
1077            TPUVersion::V4 => 32 * 1024 * 1024 * 1024,  // 32GB
1078            TPUVersion::V5e => 16 * 1024 * 1024 * 1024, // 16GB
1079            TPUVersion::V5p => 95 * 1024 * 1024 * 1024, // 95GB
1080        }
1081    }
1082
1083    fn get_interconnect_bandwidth(&self) -> f64 {
1084        match self.config.tpu_version {
1085            TPUVersion::V2 => 500.0,   // 500 GB/s
1086            TPUVersion::V3 => 900.0,   // 900 GB/s
1087            TPUVersion::V4 => 1200.0,  // 1.2 TB/s
1088            TPUVersion::V5e => 1600.0, // 1.6 TB/s
1089            TPUVersion::V5p => 4800.0, // 4.8 TB/s
1090        }
1091    }
1092}
1093
1094/// Lets `TPUOptimizer` stand in anywhere a generic `optirs_core::Optimizer` is
1095/// expected (training loops, optimizer registries, ...), rather than only
1096/// being usable through its inherent `tpu_step`.
1097///
1098/// Fixed to `Ix1` because the wrapped `base_optimizer` itself is bound to
1099/// `Optimizer<A, Ix1>` (TPU compilation targets a flattened 1-D buffer; see
1100/// the `TPUOptimizer` struct definition). `step` forwards to `tpu_step`,
1101/// which does the real compile/execute work; the learning rate accessors
1102/// forward to `base_optimizer`, which is where step's arithmetic — and thus
1103/// the rate that governs it — actually lives.
1104///
1105/// `step_list` is overridden (rather than left at the trait's default) to
1106/// forward directly to `base_optimizer.step_list`. The default implementation
1107/// calls `step` — i.e. `tpu_step` — once per tensor, and `tpu_step` always
1108/// drives `base_optimizer.step` (singular): every tensor in the list would
1109/// route through the *same* per-tensor state slot regardless of its
1110/// position, which is exactly the state-thrash the "route each index to its
1111/// own slot" contract documented on `Optimizer::step_list` warns against —
1112/// silently, for any stateful `base_optimizer` (Adam, momentum, ...) called
1113/// with more than one tensor. Delegating to `base_optimizer.step_list`
1114/// directly gives each tensor its own slot the way the wrapped optimizer
1115/// promises; the cost is that this path skips this wrapper's per-tensor XLA
1116/// compile/execute/profile bookkeeping (`step` on a single tensor still goes
1117/// through the full `tpu_step` pipeline).
1118impl<O, A> Optimizer<A, Ix1> for TPUOptimizer<O, A>
1119where
1120    A: Float
1121        + Default
1122        + Clone
1123        + Send
1124        + Sync
1125        + scirs2_core::ndarray::ScalarOperand
1126        + std::fmt::Debug,
1127    O: Optimizer<A, Ix1> + Send + Sync,
1128{
1129    fn step(
1130        &mut self,
1131        params: &Array<A, Ix1>,
1132        gradients: &Array<A, Ix1>,
1133    ) -> optirs_core::Result<Array<A, Ix1>> {
1134        // `tpu_step` reports failures through this crate's own `OptimError`
1135        // (an alias of `scirs2_core::error::CoreError`), which is a
1136        // different type from `optirs_core::OptimError`; convert at the
1137        // boundary rather than silently swallowing the distinction.
1138        self.tpu_step(params, gradients)
1139            .map_err(|e| optirs_core::OptimError::OptimizationError(e.to_string()))
1140    }
1141
1142    fn get_learning_rate(&self) -> A {
1143        self.base_optimizer.get_learning_rate()
1144    }
1145
1146    fn set_learning_rate(&mut self, learning_rate: A) {
1147        self.base_optimizer.set_learning_rate(learning_rate);
1148    }
1149
1150    fn step_list(
1151        &mut self,
1152        params_list: &[&Array<A, Ix1>],
1153        gradients_list: &[&Array<A, Ix1>],
1154    ) -> optirs_core::Result<Vec<Array<A, Ix1>>> {
1155        // See the impl-level doc comment: this bypasses `tpu_step` (and so
1156        // its XLA compile/execute/profile pipeline) specifically so each
1157        // tensor gets `base_optimizer`'s own per-index state slot instead of
1158        // sharing the single slot `tpu_step` would route every call through.
1159        // Unlike `tpu_step`, `base_optimizer.step_list` already reports
1160        // through `optirs_core::OptimError` (it is bound by the same
1161        // `Optimizer<A, Ix1>` trait this impl is for), so no conversion --
1162        // and no loss of the original structured error variant -- is needed.
1163        self.base_optimizer.step_list(params_list, gradients_list)
1164    }
1165}
1166
1167/// TPU performance metrics
1168#[derive(Debug, Clone)]
1169pub struct TPUPerformanceMetrics {
1170    pub utilization: UtilizationMetrics,
1171    pub compilation: CompilationMetrics,
1172    pub memory_usage: MemoryUsageStats,
1173    pub step_count: usize,
1174    pub cache_hit_rate: f64,
1175}
1176
1177/// Memory usage statistics
1178#[derive(Debug, Clone)]
1179pub struct MemoryUsageStats {
1180    pub total_allocated: usize,
1181    pub peak_usage: usize,
1182    pub fragmentation: f64,
1183    pub pool_efficiency: f64,
1184}
1185
1186/// TPU topology information
1187#[derive(Debug, Clone)]
1188pub struct TPUTopologyInfo {
1189    pub version: TPUVersion,
1190    pub num_cores: usize,
1191    pub topology: PodTopology,
1192    pub memory_per_core: usize,
1193    pub interconnect_bandwidth: f64,
1194}
1195
1196// Implementation details for supporting structures
1197
1198impl<A: Float + Send + Sync> TPUMemoryAllocator<A> {
1199    fn new(config: &TPUConfig) -> Result<Self> {
1200        let total_memory = match config.tpu_version {
1201            TPUVersion::V2 => 8 * 1024 * 1024 * 1024 * config.num_cores,
1202            TPUVersion::V3 => 16 * 1024 * 1024 * 1024 * config.num_cores,
1203            TPUVersion::V4 => 32 * 1024 * 1024 * 1024 * config.num_cores,
1204            TPUVersion::V5e => 16 * 1024 * 1024 * 1024 * config.num_cores,
1205            TPUVersion::V5p => 95 * 1024 * 1024 * 1024 * config.num_cores,
1206        };
1207
1208        Ok(Self {
1209            total_memory,
1210            allocated_memory: 0,
1211            fragmentation_stats: FragmentationStats {
1212                external_fragmentation: 0.0,
1213            },
1214            _phantom: std::marker::PhantomData,
1215        })
1216    }
1217
1218    fn optimize_layout(&mut self) -> Result<()> {
1219        // Implement memory layout optimization
1220        Ok(())
1221    }
1222
1223    fn get_usage_stats(&self) -> MemoryUsageStats {
1224        MemoryUsageStats {
1225            total_allocated: self.allocated_memory,
1226            peak_usage: self.allocated_memory, // Simplified
1227            fragmentation: self.fragmentation_stats.external_fragmentation,
1228            pool_efficiency: if self.total_memory > 0 {
1229                self.allocated_memory as f64 / self.total_memory as f64
1230            } else {
1231                0.0
1232            },
1233        }
1234    }
1235}
1236
1237impl TPUPodCoordinator {
1238    fn new(config: &TPUConfig) -> Result<Self> {
1239        let num_cores = match config.pod_topology {
1240            PodTopology::Single => 1,
1241            PodTopology::Pod2x2 => 4,
1242            PodTopology::Pod4x4 => 16,
1243            PodTopology::Pod8x8 => 64,
1244            PodTopology::Pod16x16 => 256,
1245            PodTopology::Pod32x32 => 1024,
1246        };
1247
1248        Ok(Self { num_cores })
1249    }
1250}
1251
1252impl TPUProfiler {
1253    fn new() -> Self {
1254        Self {
1255            timeline: Vec::new(),
1256            compilation_metrics: CompilationMetrics {
1257                compilation_time_ms: 0,
1258                optimizations_applied: 0,
1259                code_size: 0,
1260            },
1261            utilization_metrics: UtilizationMetrics {
1262                compute_utilization: 0.0,
1263                memory_bandwidth_utilization: 0.0,
1264                communication_utilization: 0.0,
1265                matrix_unit_utilization: 0.0,
1266                vector_unit_utilization: 0.0,
1267            },
1268        }
1269    }
1270}
1271
1272impl XLAComputationBuilder {
1273    fn new(optimization_level: XLAOptimizationLevel, target_config: TPUConfig) -> Self {
1274        Self {
1275            optimization_level,
1276            target_config,
1277        }
1278    }
1279}
1280
1281impl Clone for XLAComputationGraph {
1282    fn clone(&self) -> Self {
1283        Self {
1284            nodes: self.nodes.clone(),
1285            builder: XLAComputationBuilder::new(
1286                self.builder.optimization_level,
1287                self.builder.target_config.clone(),
1288            ),
1289            inputs: self.inputs.clone(),
1290            outputs: self.outputs.clone(),
1291            optimization_passes: self.optimization_passes.clone(),
1292        }
1293    }
1294}
1295
1296// ---- Deterministic program serialization and derived-metric helpers ----
1297
1298/// FNV-1a 64-bit hash over a byte slice.
1299///
1300/// Deterministic and dependency-free; used to derive a stable compilation id from the
1301/// serialized program so identical programs map to identical ids.
1302fn fnv1a_64(bytes: &[u8]) -> u64 {
1303    const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
1304    const PRIME: u64 = 0x0000_0100_0000_01b3;
1305    let mut hash = OFFSET_BASIS;
1306    for &b in bytes {
1307        hash ^= b as u64;
1308        hash = hash.wrapping_mul(PRIME);
1309    }
1310    hash
1311}
1312
1313/// Number of elements described by an XLA shape (product of its real dimensions).
1314fn shape_element_count(shape: &XLAShape) -> u64 {
1315    let rank = shape.rank.min(shape.dimensions.len());
1316    shape.dimensions[..rank].iter().map(|&d| d as u64).product()
1317}
1318
1319/// Byte size of a single element of the given XLA element type.
1320fn element_type_bytes(element_type: XLAElementType) -> usize {
1321    match element_type {
1322        XLAElementType::BF16 => 2,
1323        XLAElementType::F32 => 4,
1324    }
1325}
1326
1327/// Total byte size described by an XLA shape.
1328fn shape_byte_count(shape: &XLAShape) -> usize {
1329    (shape_element_count(shape) as usize).saturating_mul(element_type_bytes(shape.element_type))
1330}
1331
1332/// Stable byte code for an XLA element type.
1333fn element_type_code(element_type: XLAElementType) -> u8 {
1334    match element_type {
1335        XLAElementType::F32 => 1,
1336        XLAElementType::BF16 => 2,
1337    }
1338}
1339
1340/// Stable byte code for an XLA operation.
1341fn operation_code(operation: &XLAOperation) -> u8 {
1342    // The codes are the historical ones, so an encoded program keeps the same
1343    // bytes it had before the unused operations were removed.
1344    match operation {
1345        XLAOperation::Add => 0,
1346        XLAOperation::Multiply => 1,
1347    }
1348}
1349
1350/// Stable byte code for an optimization pass.
1351fn pass_code(pass: &XLAOptimizationPass) -> u8 {
1352    match pass {
1353        XLAOptimizationPass::ConstantFolding => 0,
1354        XLAOptimizationPass::DeadCodeElimination => 1,
1355        XLAOptimizationPass::OperatorFusion => 2,
1356        XLAOptimizationPass::LayoutOptimization => 3,
1357        XLAOptimizationPass::MemoryOptimization => 4,
1358        XLAOptimizationPass::TensorCoreUtilization => 5,
1359    }
1360}
1361
1362/// Encode an XLA shape into the program byte stream.
1363fn encode_shape(bytes: &mut Vec<u8>, shape: &XLAShape) {
1364    let rank = shape.rank.min(shape.dimensions.len());
1365    bytes.push(rank as u8);
1366    bytes.push(element_type_code(shape.element_type));
1367    for &dim in &shape.dimensions[..rank] {
1368        bytes.extend_from_slice(&(dim as u64).to_le_bytes());
1369    }
1370}
1371
1372/// Encode an XLA operand (id + shape) into the program byte stream.
1373fn encode_operand(bytes: &mut Vec<u8>, operand: &XLAOperand) {
1374    bytes.extend_from_slice(&(operand.id as u64).to_le_bytes());
1375    encode_shape(bytes, &operand.shape);
1376}
1377
1378/// Serialize a computation graph into a deterministic byte program.
1379///
1380/// The encoding is a stable function of the graph contents: a magic header and format
1381/// version, the XLA optimization level, the input operands (sorted by name), the node list,
1382/// the output operands and the optimization-pass pipeline. Identical graphs always produce
1383/// identical bytes, which is what makes the derived compilation id and `code_size`
1384/// reproducible.
1385fn encode_program(graph: &XLAComputationGraph) -> Vec<u8> {
1386    let mut bytes = Vec::new();
1387    bytes.extend_from_slice(b"OTPU");
1388    bytes.push(1); // format version
1389    bytes.push(graph.builder.optimization_level as u8);
1390
1391    // Inputs, sorted by name for a deterministic encoding independent of HashMap order.
1392    let mut inputs: Vec<(&String, &XLAOperand)> = graph.inputs.iter().collect();
1393    inputs.sort_by(|a, b| a.0.cmp(b.0));
1394    bytes.extend_from_slice(&(inputs.len() as u32).to_le_bytes());
1395    for (name, operand) in inputs {
1396        bytes.extend_from_slice(&(name.len() as u32).to_le_bytes());
1397        bytes.extend_from_slice(name.as_bytes());
1398        encode_operand(&mut bytes, operand);
1399    }
1400
1401    // Nodes.
1402    bytes.extend_from_slice(&(graph.nodes.len() as u32).to_le_bytes());
1403    for node in &graph.nodes {
1404        bytes.push(operation_code(&node.operation));
1405        bytes.extend_from_slice(&(node.inputs.len() as u32).to_le_bytes());
1406        for operand in &node.inputs {
1407            encode_operand(&mut bytes, operand);
1408        }
1409        encode_shape(&mut bytes, &node.outputshape);
1410        bytes.extend_from_slice(&node.metadata.flops.to_le_bytes());
1411        bytes.extend_from_slice(&(node.metadata.memory_bytes as u64).to_le_bytes());
1412    }
1413
1414    // Outputs.
1415    bytes.extend_from_slice(&(graph.outputs.len() as u32).to_le_bytes());
1416    for operand in &graph.outputs {
1417        encode_operand(&mut bytes, operand);
1418    }
1419
1420    // Optimization pipeline.
1421    bytes.extend_from_slice(&(graph.optimization_passes.len() as u32).to_le_bytes());
1422    for pass in &graph.optimization_passes {
1423        bytes.push(pass_code(pass));
1424    }
1425
1426    bytes
1427}
1428
1429#[cfg(test)]
1430mod tests {
1431    use super::*;
1432
1433    #[test]
1434    fn test_tpu_config_default() {
1435        let config = TPUConfig::default();
1436        assert_eq!(config.num_cores, 8);
1437        assert!(config.enable_xla);
1438        assert!(matches!(config.tpu_version, TPUVersion::V4));
1439    }
1440
1441    // TPUOptimizer test disabled - requires optirs-core SGD optimizer
1442    // #[test]
1443    // fn test_tpu_optimizer_creation() {
1444    //     let sgd = SGD::new(0.01);
1445    //     let config = TPUConfig::default();
1446    //     let optimizer = TPUOptimizer::new(sgd, config);
1447    //     assert!(optimizer.is_ok());
1448    // }
1449
1450    #[test]
1451    fn test_xlashape_creation() {
1452        let shape = XLAShape {
1453            dimensions: [10, 20, 1, 1],
1454            rank: 2,
1455            element_type: XLAElementType::F32,
1456        };
1457
1458        assert_eq!(shape.rank, 2);
1459        assert_eq!(shape.dimensions[0], 10);
1460        assert_eq!(shape.dimensions[1], 20);
1461    }
1462
1463    #[test]
1464    fn test_memory_allocator_creation() {
1465        let config = TPUConfig {
1466            tpu_version: TPUVersion::V4,
1467            num_cores: 8,
1468            ..Default::default()
1469        };
1470
1471        let allocator = TPUMemoryAllocator::<f32>::new(&config);
1472        assert!(allocator.is_ok());
1473
1474        let allocator = allocator.expect("unwrap failed");
1475        assert_eq!(allocator.total_memory, 32 * 1024 * 1024 * 1024 * 8); // 32GB * 8 cores
1476    }
1477
1478    use optirs_core::optimizers::SGD;
1479    use scirs2_core::ndarray::Array1;
1480
1481    fn new_sgd_tpu(config: TPUConfig) -> TPUOptimizer<SGD<f32>, f32> {
1482        TPUOptimizer::new(SGD::new(0.1f32), config).expect("failed to build TPU optimizer")
1483    }
1484
1485    #[test]
1486    fn test_tpu_step_updates_params_in_descent_direction() {
1487        // Single-device path: tpu_step must actually run the wrapped SGD update on the CPU
1488        // and move parameters against the gradient, not return an error stub.
1489        let mut optimizer = new_sgd_tpu(TPUConfig::default());
1490        optimizer
1491            .initialize_xla_graph()
1492            .expect("xla graph init failed");
1493
1494        let params = Array1::from(vec![1.0f32, 2.0, 3.0]);
1495        let gradients = Array1::from(vec![1.0f32, 1.0, 1.0]);
1496
1497        let updated = optimizer
1498            .tpu_step(&params, &gradients)
1499            .expect("tpu_step must succeed and return updated params");
1500
1501        // SGD with lr=0.1 and unit gradient: p - 0.1 * 1 = p - 0.1
1502        let expected = [0.9f32, 1.9, 2.9];
1503        assert_eq!(updated.len(), 3);
1504        for (i, (&u, &e)) in updated.iter().zip(expected.iter()).enumerate() {
1505            assert!(
1506                (u - e).abs() < 1e-6,
1507                "index {i}: updated {u} != expected {e}"
1508            );
1509            // Descent: moved strictly below the original parameter value.
1510            assert!(
1511                u < params[i],
1512                "index {i}: {u} not below original {}",
1513                params[i]
1514            );
1515        }
1516
1517        // A step was recorded.
1518        assert_eq!(optimizer.step_count, 1);
1519    }
1520
1521    #[test]
1522    fn test_tpu_step_works_without_explicit_graph_init() {
1523        // build_optimizer_computation must fall back to a default graph, so tpu_step works
1524        // even if initialize_xla_graph was never called.
1525        let mut optimizer = new_sgd_tpu(TPUConfig::default());
1526        let params = Array1::from(vec![0.5f32, -0.5]);
1527        let gradients = Array1::from(vec![1.0f32, -1.0]);
1528
1529        let updated = optimizer
1530            .tpu_step(&params, &gradients)
1531            .expect("tpu_step must succeed without prior graph init");
1532
1533        assert!((updated[0] - 0.4).abs() < 1e-6);
1534        assert!((updated[1] - (-0.4)).abs() < 1e-6);
1535    }
1536
1537    /// Generic helper that only compiles against `optirs_core::Optimizer`, never against
1538    /// `TPUOptimizer` directly. Regression test for F10 ("no Optimizer trait impl"): before
1539    /// the fix `TPUOptimizer` had no such impl, so nothing generic over `Optimizer` could
1540    /// accept it and this function would fail to instantiate at the call site below.
1541    fn run_one_generic_step<O: Optimizer<f32, scirs2_core::ndarray::Ix1>>(
1542        optimizer: &mut O,
1543        params: &Array1<f32>,
1544        gradients: &Array1<f32>,
1545    ) -> Array1<f32> {
1546        optimizer
1547            .step(params, gradients)
1548            .expect("generic Optimizer::step must succeed")
1549    }
1550
1551    #[test]
1552    fn tpu_optimizer_is_usable_through_the_optimizer_trait() {
1553        let mut optimizer = new_sgd_tpu(TPUConfig::default());
1554        let params = Array1::from(vec![1.0f32, 2.0, 3.0]);
1555        let gradients = Array1::from(vec![1.0f32, 1.0, 1.0]);
1556
1557        let updated = run_one_generic_step(&mut optimizer, &params, &gradients);
1558
1559        // Same arithmetic as `test_tpu_step_updates_params_in_descent_direction`: lr=0.1,
1560        // unit gradient, so `step` really drove `tpu_step` rather than a stub.
1561        let expected = [0.9f32, 1.9, 2.9];
1562        for (i, (&u, &e)) in updated.iter().zip(expected.iter()).enumerate() {
1563            assert!((u - e).abs() < 1e-6, "index {i}: {u} != {e}");
1564        }
1565        assert_eq!(optimizer.step_count, 1);
1566    }
1567
1568    #[test]
1569    fn tpu_optimizer_learning_rate_forwards_to_base_optimizer() {
1570        let mut optimizer = new_sgd_tpu(TPUConfig::default());
1571        assert!((Optimizer::get_learning_rate(&optimizer) - 0.1).abs() < 1e-6);
1572
1573        Optimizer::set_learning_rate(&mut optimizer, 0.5);
1574        assert!((Optimizer::get_learning_rate(&optimizer) - 0.5).abs() < 1e-6);
1575
1576        // The new rate must actually be the one `step` uses, not a value the wrapper
1577        // tracks independently of `base_optimizer`.
1578        let params = Array1::from(vec![1.0f32]);
1579        let gradients = Array1::from(vec![1.0f32]);
1580        let updated =
1581            Optimizer::step(&mut optimizer, &params, &gradients).expect("step must succeed");
1582        assert!((updated[0] - 0.5).abs() < 1e-6, "got {}", updated[0]);
1583    }
1584
1585    // Regression test: `Optimizer::step_list`'s *default* implementation calls
1586    // `step` once per tensor. For `TPUOptimizer`, `step` is `tpu_step`, which always
1587    // drives `base_optimizer.step` -- and `Adam::step` always uses moment-state index 0
1588    // (see `Adam::step`/`step_indexed`). So relying on the default would route every
1589    // tensor in the list through the *same* Adam state slot, silently mixing one
1590    // tensor's momentum into another's and resetting bias-correction timesteps whenever
1591    // shapes disagree (`Adam::advance_state` resets a slot's state, without erroring,
1592    // when it sees a shape different from what that slot last held). `TPUOptimizer`
1593    // overrides `step_list` to forward to `base_optimizer.step_list` directly, which
1594    // Adam implements by calling `step_indexed(index, ..)` -- one state slot per list
1595    // position -- specifically to avoid this. This test proves the override is wired in:
1596    // it compares the trait's `step_list` output against two independent, freshly-indexed
1597    // `step_indexed` calls (the definition of "isolated state"), and separately shows
1598    // that is NOT the same as what routing both tensors through index 0 would produce.
1599    #[test]
1600    fn tpu_optimizer_step_list_gives_each_tensor_its_own_optimizer_state() {
1601        use optirs_core::optimizers::Adam;
1602
1603        let mut optimizer = TPUOptimizer::new(Adam::new(0.1f32), TPUConfig::default())
1604            .expect("failed to build TPU optimizer");
1605
1606        let params_a = Array1::from(vec![1.0f32, 2.0]);
1607        let grads_a = Array1::from(vec![0.1f32, 0.1]);
1608        let params_b = Array1::from(vec![10.0f32, 20.0]);
1609        let grads_b = Array1::from(vec![0.5f32, 0.5]);
1610
1611        let results = Optimizer::step_list(
1612            &mut optimizer,
1613            &[&params_a, &params_b],
1614            &[&grads_a, &grads_b],
1615        )
1616        .expect("step_list must succeed");
1617        assert_eq!(results.len(), 2);
1618
1619        // Ground truth for "each tensor owns an independent, freshly-timestepped slot":
1620        // a brand-new Adam instance, called with the same per-position indices.
1621        let mut reference = Adam::new(0.1f32);
1622        let expected_a = reference
1623            .step_indexed(0, &params_a, &grads_a)
1624            .expect("reference step_indexed(0) must succeed");
1625        let expected_b = reference
1626            .step_indexed(1, &params_b, &grads_b)
1627            .expect("reference step_indexed(1) must succeed");
1628
1629        for i in 0..2 {
1630            assert!(
1631                (results[0][i] - expected_a[i]).abs() < 1e-6,
1632                "tensor 0 index {i}: {} != {}",
1633                results[0][i],
1634                expected_a[i]
1635            );
1636            assert!(
1637                (results[1][i] - expected_b[i]).abs() < 1e-6,
1638                "tensor 1 index {i}: {} != {}",
1639                results[1][i],
1640                expected_b[i]
1641            );
1642        }
1643
1644        // Demonstrate this is not a vacuous comparison: routing both tensors through
1645        // the *same* slot (what the unfixed default `step_list` would do via `tpu_step`
1646        // -> `Adam::step` -> `step_indexed(0, ..)` every time) gives a materially
1647        // different result for the second tensor, because it inherits tensor A's
1648        // momentum and a bias-correction timestep of 2 instead of a fresh 1.
1649        let mut shared_slot = Adam::new(0.1f32);
1650        let _ = shared_slot
1651            .step_indexed(0, &params_a, &grads_a)
1652            .expect("shared-slot step_indexed(0) [a] must succeed");
1653        let shared_slot_b = shared_slot
1654            .step_indexed(0, &params_b, &grads_b)
1655            .expect("shared-slot step_indexed(0) [b] must succeed");
1656        let materially_different = (0..2).any(|i| (shared_slot_b[i] - expected_b[i]).abs() > 1e-4);
1657        assert!(
1658            materially_different,
1659            "expected sharing one state slot to diverge from independent per-tensor state, \
1660             got shared={shared_slot_b:?} independent={expected_b:?}"
1661        );
1662    }
1663
1664    #[test]
1665    fn test_tpu_step_distributed_matches_single_device() {
1666        // Data-parallel path with a pod coordinator: averaging identical replicas is the
1667        // identity, so the distributed update must match the single-device update.
1668        let config = TPUConfig {
1669            enable_pod_coordination: true,
1670            pod_topology: PodTopology::Pod2x2,
1671            ..Default::default()
1672        };
1673        let mut optimizer = new_sgd_tpu(config);
1674        assert!(optimizer.pod_coordinator.is_some());
1675
1676        let params = Array1::from(vec![1.0f32, 2.0, 3.0]);
1677        let gradients = Array1::from(vec![2.0f32, 2.0, 2.0]);
1678
1679        let updated = optimizer
1680            .tpu_step(&params, &gradients)
1681            .expect("distributed tpu_step must succeed");
1682
1683        // p - 0.1 * 2 = p - 0.2
1684        let expected = [0.8f32, 1.8, 2.8];
1685        for (&u, &e) in updated.iter().zip(expected.iter()) {
1686            assert!((u - e).abs() < 1e-6, "updated {u} != expected {e}");
1687        }
1688
1689        // The all-reduce collective was recorded as a communication event.
1690        assert!(optimizer
1691            .profiler
1692            .timeline
1693            .iter()
1694            .any(|event| matches!(event.event_type, ProfileEventType::Communication)));
1695    }
1696
1697    #[test]
1698    fn test_tpu_step_shape_mismatch_errors() {
1699        let mut optimizer = new_sgd_tpu(TPUConfig::default());
1700        let params = Array1::from(vec![1.0f32, 2.0, 3.0]);
1701        let gradients = Array1::from(vec![1.0f32, 1.0]);
1702        assert!(optimizer.tpu_step(&params, &gradients).is_err());
1703    }
1704
1705    #[test]
1706    fn test_compile_to_tpu_produces_real_code_and_metrics() {
1707        let optimizer = new_sgd_tpu(TPUConfig::default());
1708        let shape = XLAShape {
1709            dimensions: [4, 1, 1, 1],
1710            rank: 1,
1711            element_type: XLAElementType::F32,
1712        };
1713        let graph = optimizer
1714            .build_optimizer_computation(&[shape, shape])
1715            .expect("graph build failed");
1716        let compiled = optimizer
1717            .compile_to_tpu(graph)
1718            .expect("compile_to_tpu failed");
1719
1720        // Code is a real serialized program: begins with the magic header, is longer than
1721        // the header, and is not the all-zero placeholder.
1722        assert!(
1723            compiled.code.len() > 4,
1724            "code too short: {}",
1725            compiled.code.len()
1726        );
1727        assert_eq!(&compiled.code[0..4], b"OTPU");
1728        assert!(
1729            compiled.code.iter().any(|&b| b != 0),
1730            "code must not be all zero"
1731        );
1732
1733        // FLOPs derived from element count, now including the graph's own
1734        // operations: 2 inputs * 4 elements * 2 flops/element = 16 from the
1735        // elementwise update, plus the two emitted nodes (multiply by the
1736        // learning rate, add to the parameters) at 4 elements each = 8.
1737        // Before `build_optimizer_computation` emitted real nodes the graph
1738        // contributed nothing here, so this used to be 16.
1739        assert_eq!(compiled.perf_characteristics.flops, 24);
1740
1741        // Utilization derived from element count; strictly within (0, 1).
1742        let util = compiled.perf_characteristics.utilization_estimate;
1743        assert!(util > 0.0 && util < 1.0, "utilization out of range: {util}");
1744
1745        // Execution time has a 1us floor and is not the old 100us literal.
1746        assert!(compiled.perf_characteristics.estimated_execution_time_us >= 1);
1747
1748        // Memory derived from real byte counts: 2 * (4 elements * 4 bytes) inputs.
1749        assert!(compiled.memory_requirements.working_memory > 0);
1750        assert!(
1751            compiled.memory_requirements.total_memory
1752                >= compiled.memory_requirements.working_memory
1753        );
1754    }
1755
1756    #[test]
1757    fn test_compile_to_tpu_is_deterministic() {
1758        let optimizer = new_sgd_tpu(TPUConfig::default());
1759        let shape = XLAShape {
1760            dimensions: [8, 1, 1, 1],
1761            rank: 1,
1762            element_type: XLAElementType::F32,
1763        };
1764        let graph_a = optimizer
1765            .build_optimizer_computation(&[shape, shape])
1766            .expect("graph build failed");
1767        let graph_b = optimizer
1768            .build_optimizer_computation(&[shape, shape])
1769            .expect("graph build failed");
1770        let a = optimizer.compile_to_tpu(graph_a).expect("compile failed");
1771        let b = optimizer.compile_to_tpu(graph_b).expect("compile failed");
1772        assert_eq!(a.code, b.code);
1773        assert_eq!(a.id, b.id);
1774    }
1775
1776    #[test]
1777    fn test_optimizations_applied_counts_only_effective_passes() {
1778        // The default graph has no nodes, so no optimization pass changes anything and
1779        // `optimizations_applied` must be 0 (not the pipeline length of 6). `code_size`
1780        // must reflect the real generated program.
1781        let mut optimizer = new_sgd_tpu(TPUConfig::default());
1782        optimizer
1783            .initialize_xla_graph()
1784            .expect("xla graph init failed");
1785        let shape = XLAShape {
1786            dimensions: [4, 1, 1, 1],
1787            rank: 1,
1788            element_type: XLAElementType::F32,
1789        };
1790        optimizer
1791            .compile_step(&[shape, shape])
1792            .expect("compile_step failed");
1793
1794        assert_eq!(
1795            optimizer.profiler.compilation_metrics.optimizations_applied,
1796            0
1797        );
1798        assert!(optimizer.profiler.compilation_metrics.code_size > 0);
1799    }
1800}