Skip to main content

rlx_fpga/
export_config.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Export configuration for FPGA / SystemVerilog emission.
17//!
18//! Separates **datapath tuning** ([`crate::tune::Tune`]) from the
19//! **hardware / synthesis target** ([`HwTarget`]). The default is
20//! target-agnostic SystemVerilog with soft ports (`clk`/`rst`/`start`/…)
21//! and a generic Yosys script — no board pins, no vendor lock-in.
22
23use std::fmt;
24use std::path::PathBuf;
25
26use crate::from_graph::FromGraphOptions;
27use crate::legalize::{ExportQuantMode, LegalizeOptions};
28use crate::tune::{OptTarget, Tune};
29
30/// FPGA / ASIC synthesis family for optional constraint + script emit.
31///
32/// The RTL itself (`top.sv` + layers) is always **target-agnostic**: soft
33/// ports only. Board-specific pinouts live in optional sidecar files
34/// (`constraints.lpf`, `synth.sh`) when a concrete [`HwTarget`] is chosen.
35#[derive(Debug, Clone, PartialEq, Eq, Default)]
36pub enum HwTarget {
37    /// Target-agnostic SystemVerilog + generic Yosys `synth` script.
38    /// Soft ports; no pin constraints. **Default.**
39    #[default]
40    Generic,
41    /// Lattice ECP5 via `yosys synth_ecp5` → `nextpnr-ecp5` → `ecppack`.
42    Ecp5 {
43        /// Device size token, e.g. `"25k"`, `"45k"`, `"85k"`.
44        device: String,
45        /// Package, e.g. `"CABGA381"`.
46        package: String,
47    },
48    /// Lattice iCE40 via `yosys synth_ice40` → `nextpnr-ice40` → `icepack`.
49    Ice40 { device: String, package: String },
50    /// Xilinx 7-series via open XC7 flow (`synth_xilinx` / nextpnr-xilinx).
51    /// Experimental — scripts are emitted; P&R success depends on the
52    /// installed open toolchain.
53    Xilinx7 {
54        /// Part name, e.g. `"xc7a35tcsg324-1"`.
55        part: String,
56    },
57}
58
59impl HwTarget {
60    /// Soft-port, board-agnostic RTL (default).
61    pub fn generic() -> Self {
62        Self::Generic
63    }
64
65    /// Common ECP5-25k / CABGA381 (ULX3S-class) preset.
66    pub fn ecp5_25k() -> Self {
67        Self::Ecp5 {
68            device: "25k".into(),
69            package: "CABGA381".into(),
70        }
71    }
72
73    /// iCE40 UP5K / SG48 preset.
74    pub fn ice40_up5k() -> Self {
75        Self::Ice40 {
76            device: "up5k".into(),
77            package: "sg48".into(),
78        }
79    }
80
81    /// Parse CLI / config strings: `generic`, `ecp5`, `ecp5:25k:CABGA381`,
82    /// `ice40`, `ice40:up5k:sg48`, `xilinx7:xc7a35tcsg324-1`.
83    pub fn parse(s: &str) -> Result<Self, String> {
84        let s = s.trim();
85        let lower = s.to_ascii_lowercase();
86        match lower.as_str() {
87            "generic" | "agnostic" | "soft" => Ok(Self::Generic),
88            "ecp5" => Ok(Self::ecp5_25k()),
89            "ice40" => Ok(Self::ice40_up5k()),
90            _ => {
91                let mut parts = s.split(':');
92                let kind = parts.next().unwrap_or("").to_ascii_lowercase();
93                match kind.as_str() {
94                    "ecp5" => {
95                        let device = parts.next().unwrap_or("25k").to_string();
96                        let package = parts.next().unwrap_or("CABGA381").to_string();
97                        Ok(Self::Ecp5 { device, package })
98                    }
99                    "ice40" => {
100                        let device = parts.next().unwrap_or("up5k").to_string();
101                        let package = parts.next().unwrap_or("sg48").to_string();
102                        Ok(Self::Ice40 { device, package })
103                    }
104                    "xilinx7" | "xc7" => {
105                        let part = parts
106                            .next()
107                            .ok_or_else(|| {
108                                "xilinx7 requires a part, e.g. xilinx7:xc7a35tcsg324-1".to_string()
109                            })?
110                            .to_string();
111                        Ok(Self::Xilinx7 { part })
112                    }
113                    _ => Err(format!(
114                        "unknown HwTarget {s:?}; expected generic | ecp5[:device:pkg] | \
115                         ice40[:device:pkg] | xilinx7:<part>"
116                    )),
117                }
118            }
119        }
120    }
121
122    /// Human-readable tag for directory names / banners.
123    pub fn tag(&self) -> String {
124        match self {
125            Self::Generic => "generic".into(),
126            Self::Ecp5 { device, package } => format!("ecp5_{device}_{package}"),
127            Self::Ice40 { device, package } => format!("ice40_{device}_{package}"),
128            Self::Xilinx7 { part } => format!("xilinx7_{part}"),
129        }
130    }
131
132    /// Whether this target wants a pin-constraint sidecar.
133    pub fn emits_constraints(&self) -> bool {
134        !matches!(self, Self::Generic)
135    }
136}
137
138impl fmt::Display for HwTarget {
139    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140        match self {
141            Self::Generic => write!(f, "generic (target-agnostic)"),
142            Self::Ecp5 { device, package } => write!(f, "ecp5 {device} {package}"),
143            Self::Ice40 { device, package } => write!(f, "ice40 {device} {package}"),
144            Self::Xilinx7 { part } => write!(f, "xilinx7 {part}"),
145        }
146    }
147}
148
149/// What the soft `top` presents as its primary result.
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
151pub enum OutputKind {
152    /// Single-byte class index from Argmax (default TinyConv path).
153    #[default]
154    Argmax,
155    /// Last Dense logits only — no Argmax layer; `pred` is logits[0] for TB
156    /// compatibility, and `logits_len` is noted in EXPORT.md.
157    Logits,
158}
159
160/// Soft-port signal names for `top.sv` (ASIC / SoC integration).
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct PortNames {
163    pub clk: String,
164    pub rst: String,
165    pub start: String,
166    pub done: String,
167    pub in_addr: String,
168    pub in_we: String,
169    pub in_din: String,
170    pub out_addr: String,
171    pub out_re: String,
172    pub out_dout: String,
173    pub pred: String,
174    pub in_valid: String,
175    pub in_ready: String,
176    pub in_data: String,
177    pub out_valid: String,
178    pub out_ready: String,
179    pub out_data: String,
180}
181
182impl Default for PortNames {
183    fn default() -> Self {
184        Self {
185            clk: "clk".into(),
186            rst: "rst".into(),
187            start: "start".into(),
188            done: "done".into(),
189            in_addr: "in_addr".into(),
190            in_we: "in_we".into(),
191            in_din: "in_din".into(),
192            out_addr: "out_addr".into(),
193            out_re: "out_re".into(),
194            out_dout: "out_dout".into(),
195            pred: "pred".into(),
196            in_valid: "in_valid".into(),
197            in_ready: "in_ready".into(),
198            in_data: "in_data".into(),
199            out_valid: "out_valid".into(),
200            out_ready: "out_ready".into(),
201            out_data: "out_data".into(),
202        }
203    }
204}
205
206/// How the host loads the activation input buffer.
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
208pub enum InputIface {
209    /// Byte poke via `in_addr` / `in_we` / `in_din` (default).
210    #[default]
211    Memory,
212    /// Streaming `valid`/`ready` + packed INT8 beats.
213    Stream {
214        /// Elements (bytes) per beat; must be a power of two ≥ 1.
215        beat_elems: u8,
216    },
217    /// Both memory poke and streaming load.
218    MemoryAndStream { beat_elems: u8 },
219}
220
221impl InputIface {
222    pub fn parse(s: &str) -> Result<Self, String> {
223        let s = s.trim().to_ascii_lowercase();
224        match s.as_str() {
225            "memory" | "mem" | "poke" => Ok(Self::Memory),
226            "stream" => Ok(Self::Stream { beat_elems: 1 }),
227            "both" | "memory+stream" | "mem+stream" => Ok(Self::MemoryAndStream { beat_elems: 1 }),
228            other if other.starts_with("stream:") => {
229                let n: u8 = other[7..].parse().map_err(|_| {
230                    format!("bad stream beat width in {other:?}; expected stream:N")
231                })?;
232                Ok(Self::Stream {
233                    beat_elems: n.max(1),
234                })
235            }
236            other if other.starts_with("both:") => {
237                let n: u8 = other[5..]
238                    .parse()
239                    .map_err(|_| format!("bad both beat width in {other:?}; expected both:N"))?;
240                Ok(Self::MemoryAndStream {
241                    beat_elems: n.max(1),
242                })
243            }
244            _ => Err(format!(
245                "unknown InputIface {s:?}; expected memory | stream | both \
246                 [|stream:N|both:N]"
247            )),
248        }
249    }
250
251    pub fn wants_memory(self) -> bool {
252        matches!(self, Self::Memory | Self::MemoryAndStream { .. })
253    }
254
255    pub fn wants_stream(self) -> bool {
256        matches!(self, Self::Stream { .. } | Self::MemoryAndStream { .. })
257    }
258
259    pub fn beat_elems(self) -> u8 {
260        match self {
261            Self::Memory => 1,
262            Self::Stream { beat_elems } | Self::MemoryAndStream { beat_elems } => beat_elems.max(1),
263        }
264    }
265}
266
267/// How the host observes the primary result / last activation buffer.
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
269pub enum OutputIface {
270    /// Single-byte `pred` (default for Argmax).
271    #[default]
272    ScalarPred,
273    /// Memory readout via `out_addr` / `out_re` / `out_dout`.
274    MemoryReadout,
275    /// Streaming `valid`/`ready` + packed INT8 beats after `done`.
276    Stream { beat_elems: u8 },
277    /// Scalar `pred` plus full memory readout (default for Logits).
278    ScalarAndMemory,
279    /// Memory readout plus streaming.
280    MemoryAndStream { beat_elems: u8 },
281}
282
283impl OutputIface {
284    pub fn parse(s: &str) -> Result<Self, String> {
285        let s = s.trim().to_ascii_lowercase();
286        match s.as_str() {
287            "scalar" | "pred" | "argmax" => Ok(Self::ScalarPred),
288            "memory" | "mem" | "readout" => Ok(Self::MemoryReadout),
289            "stream" => Ok(Self::Stream { beat_elems: 1 }),
290            "scalar+memory" | "scalar_and_memory" | "both_scalar" => Ok(Self::ScalarAndMemory),
291            "both" | "memory+stream" | "mem+stream" => Ok(Self::MemoryAndStream { beat_elems: 1 }),
292            other if other.starts_with("stream:") => {
293                let n: u8 = other[7..].parse().map_err(|_| {
294                    format!("bad stream beat width in {other:?}; expected stream:N")
295                })?;
296                Ok(Self::Stream {
297                    beat_elems: n.max(1),
298                })
299            }
300            other if other.starts_with("both:") => {
301                let n: u8 = other[5..]
302                    .parse()
303                    .map_err(|_| format!("bad both beat width in {other:?}; expected both:N"))?;
304                Ok(Self::MemoryAndStream {
305                    beat_elems: n.max(1),
306                })
307            }
308            _ => Err(format!(
309                "unknown OutputIface {s:?}; expected scalar | memory | stream | \
310                 scalar+memory | both [|stream:N|both:N]"
311            )),
312        }
313    }
314
315    pub fn wants_memory(self) -> bool {
316        matches!(
317            self,
318            Self::MemoryReadout | Self::ScalarAndMemory | Self::MemoryAndStream { .. }
319        )
320    }
321
322    pub fn wants_stream(self) -> bool {
323        matches!(self, Self::Stream { .. } | Self::MemoryAndStream { .. })
324    }
325
326    /// Emit the scalar `pred` port (addr-0 peek and/or class index).
327    pub fn wants_pred_port(self) -> bool {
328        !matches!(self, Self::MemoryReadout)
329    }
330
331    pub fn beat_elems(self) -> u8 {
332        match self {
333            Self::Stream { beat_elems } | Self::MemoryAndStream { beat_elems } => beat_elems.max(1),
334            _ => 1,
335        }
336    }
337}
338
339/// Bind Graph tensor names to the soft datapath ports.
340#[derive(Debug, Clone, PartialEq, Eq, Default)]
341pub struct GraphIoBind {
342    /// Graph `Op::Input` name; `None` = first input.
343    pub input: Option<String>,
344    /// Graph output / layer names. Empty = default single `g.outputs[0]`.
345    /// First entry is the primary result; extras become additional readout banks.
346    pub outputs: Vec<String>,
347    /// Extra scalar / small `Op::Input` names exposed as soft sideband ports
348    /// (not part of the MAC datapath). Widths come from the tensor shape.
349    pub sideband_inputs: Vec<String>,
350}
351
352impl GraphIoBind {
353    pub fn with_input(mut self, name: impl Into<String>) -> Self {
354        self.input = Some(name.into());
355        self
356    }
357
358    pub fn with_outputs<I, S>(mut self, names: I) -> Self
359    where
360        I: IntoIterator<Item = S>,
361        S: Into<String>,
362    {
363        self.outputs = names.into_iter().map(Into::into).collect();
364        self
365    }
366
367    pub fn with_sideband_inputs<I, S>(mut self, names: I) -> Self
368    where
369        I: IntoIterator<Item = S>,
370        S: Into<String>,
371    {
372        self.sideband_inputs = names.into_iter().map(Into::into).collect();
373        self
374    }
375}
376
377/// Soft scalar / status channel on `top` — not part of the activation BRAM path.
378///
379/// Typical uses: temperature, batch id, mode flags. The host drives the input
380/// port; the value is sampled when [`PortNames::start`] asserts and (by default)
381/// echoed on `{name}_q` for SoC observability.
382#[derive(Debug, Clone, PartialEq, Eq)]
383pub struct SidebandSpec {
384    /// Port stem (sanitized to a SV identifier).
385    pub name: String,
386    /// Bit width in `1..=64`.
387    pub bits: u8,
388    /// Emit as `signed` packed logic.
389    pub signed: bool,
390    /// Also emit `{name}_q` output holding the value captured at `start`.
391    pub echo: bool,
392}
393
394impl SidebandSpec {
395    /// Unsigned input sideband of `bits` width with echo output.
396    pub fn input(name: impl Into<String>, bits: u8) -> Self {
397        Self {
398            name: name.into(),
399            bits: bits.clamp(1, 64),
400            signed: false,
401            echo: true,
402        }
403    }
404
405    /// Signed input sideband (e.g. INT8 sensor).
406    pub fn signed_input(name: impl Into<String>, bits: u8) -> Self {
407        Self {
408            name: name.into(),
409            bits: bits.clamp(1, 64),
410            signed: true,
411            echo: true,
412        }
413    }
414
415    pub fn with_echo(mut self, echo: bool) -> Self {
416        self.echo = echo;
417        self
418    }
419
420    /// Parse `name`, `name:BITS`, or `name:BITS:signed` / `name:BITS:u`.
421    pub fn parse(s: &str) -> Result<Self, String> {
422        let s = s.trim();
423        if s.is_empty() {
424            return Err("empty sideband spec".into());
425        }
426        let mut parts = s.split(':');
427        let name = parts.next().unwrap_or("").trim().to_string();
428        if name.is_empty() {
429            return Err(format!("bad sideband {s:?}; expected name[:bits[:signed]]"));
430        }
431        let bits: u8 = match parts.next() {
432            None => 8,
433            Some(b) => b
434                .parse()
435                .map_err(|_| format!("bad sideband width in {s:?}"))?,
436        };
437        let mut signed = false;
438        if let Some(flag) = parts.next() {
439            match flag.trim().to_ascii_lowercase().as_str() {
440                "s" | "signed" | "i" => signed = true,
441                "u" | "unsigned" => signed = false,
442                other => {
443                    return Err(format!(
444                        "bad sideband signedness {other:?}; expected signed|unsigned"
445                    ));
446                }
447            }
448        }
449        Ok(Self {
450            name,
451            bits: bits.clamp(1, 64),
452            signed,
453            echo: true,
454        })
455    }
456}
457
458/// Soft I/O configuration for target-agnostic SystemVerilog export.
459#[derive(Debug, Clone, PartialEq, Eq)]
460pub struct IoConfig {
461    pub names: PortNames,
462    pub input: InputIface,
463    pub output: OutputIface,
464    pub bind: GraphIoBind,
465    /// Scalar sideband ports (temperature, batch id, …).
466    pub sidebands: Vec<SidebandSpec>,
467}
468
469impl Default for IoConfig {
470    fn default() -> Self {
471        Self {
472            names: PortNames::default(),
473            input: InputIface::Memory,
474            output: OutputIface::ScalarPred,
475            bind: GraphIoBind::default(),
476            sidebands: Vec::new(),
477        }
478    }
479}
480
481impl IoConfig {
482    pub fn new() -> Self {
483        Self::default()
484    }
485
486    pub fn with_names(mut self, names: PortNames) -> Self {
487        self.names = names;
488        self
489    }
490
491    pub fn with_input(mut self, iface: InputIface) -> Self {
492        self.input = iface;
493        self
494    }
495
496    pub fn with_output(mut self, iface: OutputIface) -> Self {
497        self.output = iface;
498        self
499    }
500
501    pub fn with_bind(mut self, bind: GraphIoBind) -> Self {
502        self.bind = bind;
503        self
504    }
505
506    pub fn with_sidebands(mut self, sidebands: Vec<SidebandSpec>) -> Self {
507        self.sidebands = sidebands;
508        self
509    }
510
511    pub fn sideband(mut self, spec: SidebandSpec) -> Self {
512        self.sidebands.push(spec);
513        self
514    }
515}
516
517/// Full FPGA export configuration: tune + hardware target + Graph bindings.
518#[derive(Debug, Clone)]
519pub struct FpgaExportConfig {
520    /// Datapath optimization preset / knobs.
521    pub tune: Tune,
522    /// Synthesis / board target. Default: [`HwTarget::Generic`].
523    pub hw_target: HwTarget,
524    /// Write `synth.sh` + `EXPORT.md` next to the RTL tree.
525    pub emit_synth_scripts: bool,
526    /// Write a stub constraint file when `hw_target` is board-specific.
527    pub emit_constraints: bool,
528    /// Emit `board_top.sv` shell for board targets.
529    pub emit_board_shell: bool,
530    /// PTQ mode when the Graph is still f32 (`Int8` / `Int4` / `Fp4`).
531    pub quant_mode: ExportQuantMode,
532    /// Weight / scale bindings for f32 → quantized legalization.
533    pub legalize: LegalizeOptions,
534    /// Prefer Argmax vs logits-only output.
535    pub output_kind: OutputKind,
536    /// Soft-port naming + memory/stream protocols + Graph tensor bind.
537    pub io: IoConfig,
538    /// Optional overrides when lowering an already-quantized Graph.
539    pub from_graph: FromGraphOptions,
540    /// Optional output directory hint (CLI / runtime may override).
541    pub out_dir: Option<PathBuf>,
542}
543
544impl Default for FpgaExportConfig {
545    fn default() -> Self {
546        Self {
547            tune: Tune::default(),
548            hw_target: HwTarget::Generic,
549            emit_synth_scripts: true,
550            emit_constraints: true,
551            emit_board_shell: true,
552            quant_mode: ExportQuantMode::Int8,
553            legalize: LegalizeOptions::default(),
554            output_kind: OutputKind::Argmax,
555            io: IoConfig::default(),
556            from_graph: FromGraphOptions::default(),
557            out_dir: None,
558        }
559    }
560}
561
562impl FpgaExportConfig {
563    pub fn new() -> Self {
564        Self::default()
565    }
566
567    pub fn for_opt_target(target: OptTarget) -> Self {
568        Self {
569            tune: Tune::for_target(target),
570            ..Self::default()
571        }
572    }
573
574    pub fn with_tune(mut self, tune: Tune) -> Self {
575        self.tune = tune;
576        self
577    }
578
579    pub fn with_hw_target(mut self, hw: HwTarget) -> Self {
580        self.hw_target = hw;
581        self
582    }
583
584    pub fn with_quant_mode(mut self, mode: ExportQuantMode) -> Self {
585        self.quant_mode = mode;
586        self.from_graph.weight_bits = mode.weight_bits();
587        self.from_graph.weight_encoding = mode.encoding();
588        self
589    }
590
591    pub fn with_legalize(mut self, opts: LegalizeOptions) -> Self {
592        self.legalize = opts;
593        self
594    }
595
596    pub fn with_output_kind(mut self, kind: OutputKind) -> Self {
597        self.output_kind = kind;
598        self.legalize.append_argmax = matches!(kind, OutputKind::Argmax);
599        // Logits need a full-vector readout; upgrade the default scalar-only iface.
600        if matches!(kind, OutputKind::Logits) && matches!(self.io.output, OutputIface::ScalarPred) {
601            self.io.output = OutputIface::ScalarAndMemory;
602        }
603        self
604    }
605
606    pub fn with_io(mut self, io: IoConfig) -> Self {
607        self.io = io;
608        self
609    }
610
611    pub fn with_port_names(mut self, names: PortNames) -> Self {
612        self.io.names = names;
613        self
614    }
615
616    pub fn with_input_iface(mut self, iface: InputIface) -> Self {
617        self.io.input = iface;
618        self
619    }
620
621    pub fn with_output_iface(mut self, iface: OutputIface) -> Self {
622        self.io.output = iface;
623        self
624    }
625
626    pub fn bind_input(mut self, name: impl Into<String>) -> Self {
627        self.io.bind.input = Some(name.into());
628        self
629    }
630
631    pub fn bind_outputs<I, S>(mut self, names: I) -> Self
632    where
633        I: IntoIterator<Item = S>,
634        S: Into<String>,
635    {
636        self.io.bind.outputs = names.into_iter().map(Into::into).collect();
637        self
638    }
639
640    pub fn bind_sideband_inputs<I, S>(mut self, names: I) -> Self
641    where
642        I: IntoIterator<Item = S>,
643        S: Into<String>,
644    {
645        self.io.bind.sideband_inputs = names.into_iter().map(Into::into).collect();
646        self
647    }
648
649    pub fn with_sidebands(mut self, sidebands: Vec<SidebandSpec>) -> Self {
650        self.io.sidebands = sidebands;
651        self
652    }
653
654    pub fn sideband(mut self, spec: SidebandSpec) -> Self {
655        self.io.sidebands.push(spec);
656        self
657    }
658
659    pub fn with_from_graph(mut self, opts: FromGraphOptions) -> Self {
660        self.from_graph = opts;
661        self
662    }
663
664    pub fn with_out_dir(mut self, dir: impl Into<PathBuf>) -> Self {
665        self.out_dir = Some(dir.into());
666        self
667    }
668
669    pub fn without_synth_scripts(mut self) -> Self {
670        self.emit_synth_scripts = false;
671        self
672    }
673}