Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion crates/synthesizer/src/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,8 @@ pub fn compute_timing_top_n(
.addr
.iter()
.chain(wp.data.iter())
.chain(std::iter::once(&wp.enable));
.chain(std::iter::once(&wp.enable))
.chain(wp.mask.iter().flatten());
for &net in inputs {
endpoints.push((
arrival[net as usize],
Expand Down
4 changes: 4 additions & 0 deletions crates/synthesizer/src/conv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,10 @@ impl ConvContext {
addr: wp.addr.iter().map(|&n| remap(n)).collect(),
data: wp.data.iter().map(|&n| remap(n)).collect(),
enable: remap(wp.enable),
mask: wp
.mask
.as_ref()
.map(|m| m.iter().map(|&n| remap(n)).collect()),
})
.collect(),
};
Expand Down
85 changes: 83 additions & 2 deletions crates/synthesizer/src/conv/ram.rs
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,74 @@ fn write_factor_sig(s: &mut String, factor: &Factor) {
}
}

/// If `expr` is a masked read-modify-write of RAM `vid` at `wr_index` —
/// `(vid[wr_index] & ~m) | (d & m)`, each `&`/`|` commuting — return `(d, m)`.
/// The retention read `vid[wr_index]` folds into the mask, so it costs no read
/// port and a lookup-plus-RMW array stays 1R1W. Port counting (`read_pattern_ok`)
/// and building (`conv::statement`) both call this, so they agree on which reads
/// are retention reads.
pub(crate) fn match_masked_write<'a>(
vid: air::VarId,
wr_index: &air::VarIndex,
expr: &'a Expression,
) -> Option<(&'a Expression, &'a Expression)> {
let Expression::Binary(a, air::Op::BitOr, b, _) = expr else {
return None;
};
match_masked_arms(vid, wr_index, a, b).or_else(|| match_masked_arms(vid, wr_index, b, a))
}

/// `retain` = `vid[wr_index] & ~m`, `write` = `d & m`. The two masks must be
/// structurally identical, else it isn't a clean masked write — some bits would
/// be both kept and written, or neither.
fn match_masked_arms<'a>(
vid: air::VarId,
wr_index: &air::VarIndex,
retain: &'a Expression,
write: &'a Expression,
) -> Option<(&'a Expression, &'a Expression)> {
let Expression::Binary(ra, air::Op::BitAnd, rb, _) = retain else {
return None;
};
let notm: &Expression = if is_self_read(vid, wr_index, ra) {
rb
} else if is_self_read(vid, wr_index, rb) {
ra
} else {
return None;
};
let Expression::Unary(air::Op::BitNot, m_retain, _) = notm else {
return None;
};
let Expression::Binary(wa, air::Op::BitAnd, wb, _) = write else {
return None;
};
let m_sig = addr_signature(m_retain);
if addr_signature(wb) == m_sig {
Some((wa, wb))
} else if addr_signature(wa) == m_sig {
Some((wb, wa))
} else {
None
}
}

/// `expr` is exactly `vid[wr_index]`: a whole-word self-read at the write's own
/// index, no bit/part select.
fn is_self_read(vid: air::VarId, wr_index: &air::VarIndex, expr: &Expression) -> bool {
let Expression::Term(factor) = expr else {
return false;
};
let Factor::Variable(id, index, select, _) = &**factor else {
return false;
};
*id == vid
&& select.is_empty()
&& index.0.len() == 1
&& wr_index.0.len() == 1
&& addr_signature(&index.0[0]) == addr_signature(&wr_index.0[0])
}

/// `mem[addr]` with a single dynamic index dimension and no bit/part select.
fn is_dynamic_whole_word(dst: &AssignDestination) -> bool {
dst.index.0.len() == 1
Expand Down Expand Up @@ -540,8 +608,21 @@ fn for_each_read_in_dsts(dsts: &[AssignDestination], vid: air::VarId, f: &mut Re
fn for_each_read_in_stmt(stmt: &Statement, vid: air::VarId, f: &mut ReadVisitor) {
match stmt {
Statement::Assign(a) => {
for_each_read_in_expr(&a.expr, vid, f);
for_each_read_in_dsts(&a.dst, vid, f);
// A masked write folds its retention read into the mask (see
// `match_masked_write`), so count only `d`/`m`/dst, not that read. A
// genuine read at the same index elsewhere is still a distinct
// factor and counted.
if a.dst.len() == 1
&& a.dst[0].id == vid
&& let Some((d, m)) = match_masked_write(vid, &a.dst[0].index, &a.expr)
{
for_each_read_in_expr(d, vid, f);
for_each_read_in_expr(m, vid, f);
for_each_read_in_dsts(&a.dst, vid, f);
} else {
for_each_read_in_expr(&a.expr, vid, f);
for_each_read_in_dsts(&a.dst, vid, f);
}
}
Statement::If(i) => {
for_each_read_in_expr(&i.cond, vid, f);
Expand Down
49 changes: 48 additions & 1 deletion crates/synthesizer/src/conv/statement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ fn process_statement(
) -> Result<(), SynthesizerError> {
match stmt {
Statement::Assign(a) => {
// Byte-write-enable fold: record a masked RMW as one masked write
// port (see `ram::match_masked_write`), not a write + retention read.
if a.dst.len() == 1 && ctx.ram_vars.contains_key(&a.dst[0].id) {
let dst = &a.dst[0];
if let Some((d_expr, m_expr)) = ram::match_masked_write(dst.id, &dst.index, &a.expr)
{
return record_masked_ram_write(ctx, dst, d_expr, m_expr, current);
}
}
if a.dst.len() > 1 {
// Concat-LHS `{d, e, ...} = a` — slice MSB-first so dst[0]
// gets the high bits.
Expand Down Expand Up @@ -133,7 +142,45 @@ fn record_ram_write(
data.resize(cand.width, NET_CONST0);
let enable = current_write_enable(ctx);
if let Some(builder) = ctx.ram_builders.get_mut(&dst.id) {
builder.writes.push(RamWritePort { addr, data, enable });
builder.writes.push(RamWritePort {
addr,
data,
enable,
mask: None,
});
}
Ok(())
}

/// Records a masked RAM write as a single port carrying byte-enable `mask`; only
/// `d`/`m` are synthesized, the retention read folds into the mask. Detection is
/// [`ram::match_masked_write`], shared with port counting.
fn record_masked_ram_write(
ctx: &mut ConvContext,
dst: &air::AssignDestination,
d_expr: &air::Expression,
m_expr: &air::Expression,
current: &mut HashMap<air::VarId, Vec<NetId>>,
) -> Result<(), SynthesizerError> {
let cand = ctx.ram_vars[&dst.id];
let idx_expr =
dst.index.0.first().ok_or_else(|| {
SynthesizerError::internal(format!("RAM write {} has no index", dst.id))
})?;
let idx_bits = arith::index_bits_for(cand.depth);
let addr = synthesize_expr(ctx, idx_expr, current, idx_bits)?;
let mut data = synthesize_expr(ctx, d_expr, current, cand.width)?;
data.resize(cand.width, NET_CONST0);
let mut mask = synthesize_expr(ctx, m_expr, current, cand.width)?;
mask.resize(cand.width, NET_CONST0);
let enable = current_write_enable(ctx);
if let Some(builder) = ctx.ram_builders.get_mut(&dst.id) {
builder.writes.push(RamWritePort {
addr,
data,
enable,
mask: Some(mask),
});
}
Ok(())
}
Expand Down
12 changes: 12 additions & 0 deletions crates/synthesizer/src/ir.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,11 +239,17 @@ pub struct FfCell {
/// One synchronous write port of a [`RamBlock`]. `data`/`addr`/`enable` are
/// driven by surrounding logic; the write commits on the RAM's clock edge when
/// `enable` is high. `addr`/`data` are LSB-first.
///
/// `mask`, when `Some(m)` (`m.len() == data.len()`), is a byte-/bit-write-enable:
/// bit `i` is written where `m[i]` is high, retained otherwise. `None` is an
/// unconditional whole-word write. Inferred from a read-modify-write (see
/// `conv::ram::match_masked_write`); either way it is one write port.
#[derive(Clone)]
pub struct RamWritePort {
pub addr: Vec<NetId>,
pub data: Vec<NetId>,
pub enable: NetId,
pub mask: Option<Vec<NetId>>,
}

/// One read port of a [`RamBlock`]. `data` nets are *outputs* — the RAM drives
Expand Down Expand Up @@ -293,6 +299,9 @@ impl GateModule {
wp.addr.iter().for_each(|&n| f(n));
wp.data.iter().for_each(|&n| f(n));
f(wp.enable);
if let Some(mask) = &wp.mask {
mask.iter().for_each(|&n| f(n));
}
}
for rp in &ram.read_ports {
rp.addr.iter().for_each(|&n| f(n));
Expand All @@ -310,6 +319,9 @@ impl GateModule {
wp.addr.iter_mut().for_each(&mut f);
wp.data.iter_mut().for_each(&mut f);
f(&mut wp.enable);
if let Some(mask) = &mut wp.mask {
mask.iter_mut().for_each(&mut f);
}
}
for rp in &mut ram.read_ports {
rp.addr.iter_mut().for_each(&mut f);
Expand Down
103 changes: 103 additions & 0 deletions crates/synthesizer/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2647,6 +2647,108 @@ fn single_port_memory_infers_ram() {
assert!(result.area.memory > 0.0);
}

#[test]
fn byte_write_enable_folds_retention_read() {
// A read-modify-write `mem[a] = (mem[a] & ~m) | (d & m)` is a byte-write-
// enable: the retention read `mem[a]` supplies the "keep old bits" input and
// must fold into the write port's mask, NOT allocate a second read port. So
// this lookup-plus-RMW array is 1R1W (one lookup read + one masked write),
// not 2R1W (lookup read + retention read + write).
let code = r#"
module Bwe (
clk: input clock ,
we: input logic ,
waddr: input logic<6> ,
wdata: input logic<32> ,
wmask: input logic<32> ,
raddr: input logic<6> ,
rdata: output logic<32> ,
) {
var mem: logic<32> [64];
always_ff (clk) {
if we {
mem[waddr] = (mem[waddr] & ~wmask) | (wdata & wmask);
}
}
assign rdata = mem[raddr];
}
"#;
let (ir, top) = analyze(code, "Bwe");
let result = synthesize(&ir, top, Library::default()).expect("synthesize");
let m = &result.gate_ir.module;

assert_eq!(m.ram_blocks.len(), 1, "expected one inferred RAM block");
let ram = &m.ram_blocks[0];
assert_eq!(ram.depth, 64);
assert_eq!(ram.width, 32);
// The retention read folded into the mask → still 1 read (the lookup) + 1
// masked write. Without the fold this array would be 2R1W.
assert_eq!(
ram.read_ports.len(),
1,
"retention read must fold into the byte-enable, not a 2nd read port"
);
assert_eq!(ram.write_ports.len(), 1);
let wp = &ram.write_ports[0];
let mask = wp
.mask
.as_ref()
.expect("masked write must carry a byte-enable");
assert_eq!(mask.len(), ram.width, "mask width must equal data width");
assert_eq!(m.ffs.len(), 0, "RAM array must not expand to flip-flops");
}

#[test]
fn byte_write_enable_frees_a_read_port_near_the_limit() {
// RAM_MAX_READ_PORTS is 16. With 16 distinct lookup reads the array is
// exactly at the limit; a masked write's retention read would be a 17th
// distinct read address and push it over, collapsing the array to
// flip-flops. Because the retention read folds into the byte-enable, the
// count stays 16 and the array still infers as a 16R1W RAM.
let n = 16;
let mut raddr_ports = String::new();
let mut read_expr = String::new();
for i in 0..n {
raddr_ports.push_str(&format!(" ra{i}: input logic<6> ,\n"));
if i > 0 {
read_expr.push_str(" ^ ");
}
read_expr.push_str(&format!("mem[ra{i}]"));
}
let code = format!(
r#"
module BweLimit (
clk: input clock ,
we: input logic ,
waddr: input logic<6> ,
wdata: input logic<32> ,
wmask: input logic<32> ,
{raddr_ports} rdata: output logic<32> ,
) {{
var mem: logic<32> [64];
always_ff (clk) {{
if we {{
mem[waddr] = (mem[waddr] & ~wmask) | (wdata & wmask);
}}
}}
assign rdata = {read_expr};
}}
"#
);
let (ir, top) = analyze(&code, "BweLimit");
let result = synthesize(&ir, top, Library::default()).expect("synthesize");
let m = &result.gate_ir.module;
assert_eq!(
m.ram_blocks.len(),
1,
"16 lookups + a folded retention read must still infer as RAM, not flops"
);
assert_eq!(m.ram_blocks[0].read_ports.len(), 16);
assert_eq!(m.ram_blocks[0].write_ports.len(), 1);
assert!(m.ram_blocks[0].write_ports[0].mask.is_some());
assert_eq!(m.ffs.len(), 0, "array must not expand to flip-flops");
}

#[test]
fn reset_array_stays_flip_flops_by_default() {
// Real SRAM has no reset, so a reset array is always kept as flip-flops; an
Expand Down Expand Up @@ -3116,6 +3218,7 @@ fn ram_block_area_timing_power_and_dump() {
addr: vec![0; ADDR_W],
data: vec![0; WIDTH],
enable: 0,
mask: None,
}],
}],
};
Expand Down
Loading