Skip to content

Commit bd9605c

Browse files
authored
Merge pull request #3004 from veryl-lang/feat/synth-byte-write-enable-ram
feat(synthesizer): infer byte-write-enable RAM, folding a masked-write retention read into the write port (1R1W)
2 parents 7524bc5 + d261542 commit bd9605c

6 files changed

Lines changed: 252 additions & 4 deletions

File tree

crates/synthesizer/src/analysis.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -560,7 +560,8 @@ pub fn compute_timing_top_n(
560560
.addr
561561
.iter()
562562
.chain(wp.data.iter())
563-
.chain(std::iter::once(&wp.enable));
563+
.chain(std::iter::once(&wp.enable))
564+
.chain(wp.mask.iter().flatten());
564565
for &net in inputs {
565566
endpoints.push((
566567
arrival[net as usize],

crates/synthesizer/src/conv.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -836,6 +836,10 @@ impl ConvContext {
836836
addr: wp.addr.iter().map(|&n| remap(n)).collect(),
837837
data: wp.data.iter().map(|&n| remap(n)).collect(),
838838
enable: remap(wp.enable),
839+
mask: wp
840+
.mask
841+
.as_ref()
842+
.map(|m| m.iter().map(|&n| remap(n)).collect()),
839843
})
840844
.collect(),
841845
};

crates/synthesizer/src/conv/ram.rs

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,74 @@ fn write_factor_sig(s: &mut String, factor: &Factor) {
429429
}
430430
}
431431

432+
/// If `expr` is a masked read-modify-write of RAM `vid` at `wr_index` —
433+
/// `(vid[wr_index] & ~m) | (d & m)`, each `&`/`|` commuting — return `(d, m)`.
434+
/// The retention read `vid[wr_index]` folds into the mask, so it costs no read
435+
/// port and a lookup-plus-RMW array stays 1R1W. Port counting (`read_pattern_ok`)
436+
/// and building (`conv::statement`) both call this, so they agree on which reads
437+
/// are retention reads.
438+
pub(crate) fn match_masked_write<'a>(
439+
vid: air::VarId,
440+
wr_index: &air::VarIndex,
441+
expr: &'a Expression,
442+
) -> Option<(&'a Expression, &'a Expression)> {
443+
let Expression::Binary(a, air::Op::BitOr, b, _) = expr else {
444+
return None;
445+
};
446+
match_masked_arms(vid, wr_index, a, b).or_else(|| match_masked_arms(vid, wr_index, b, a))
447+
}
448+
449+
/// `retain` = `vid[wr_index] & ~m`, `write` = `d & m`. The two masks must be
450+
/// structurally identical, else it isn't a clean masked write — some bits would
451+
/// be both kept and written, or neither.
452+
fn match_masked_arms<'a>(
453+
vid: air::VarId,
454+
wr_index: &air::VarIndex,
455+
retain: &'a Expression,
456+
write: &'a Expression,
457+
) -> Option<(&'a Expression, &'a Expression)> {
458+
let Expression::Binary(ra, air::Op::BitAnd, rb, _) = retain else {
459+
return None;
460+
};
461+
let notm: &Expression = if is_self_read(vid, wr_index, ra) {
462+
rb
463+
} else if is_self_read(vid, wr_index, rb) {
464+
ra
465+
} else {
466+
return None;
467+
};
468+
let Expression::Unary(air::Op::BitNot, m_retain, _) = notm else {
469+
return None;
470+
};
471+
let Expression::Binary(wa, air::Op::BitAnd, wb, _) = write else {
472+
return None;
473+
};
474+
let m_sig = addr_signature(m_retain);
475+
if addr_signature(wb) == m_sig {
476+
Some((wa, wb))
477+
} else if addr_signature(wa) == m_sig {
478+
Some((wb, wa))
479+
} else {
480+
None
481+
}
482+
}
483+
484+
/// `expr` is exactly `vid[wr_index]`: a whole-word self-read at the write's own
485+
/// index, no bit/part select.
486+
fn is_self_read(vid: air::VarId, wr_index: &air::VarIndex, expr: &Expression) -> bool {
487+
let Expression::Term(factor) = expr else {
488+
return false;
489+
};
490+
let Factor::Variable(id, index, select, _) = &**factor else {
491+
return false;
492+
};
493+
*id == vid
494+
&& select.is_empty()
495+
&& index.0.len() == 1
496+
&& wr_index.0.len() == 1
497+
&& addr_signature(&index.0[0]) == addr_signature(&wr_index.0[0])
498+
}
499+
432500
/// `mem[addr]` with a single dynamic index dimension and no bit/part select.
433501
fn is_dynamic_whole_word(dst: &AssignDestination) -> bool {
434502
dst.index.0.len() == 1
@@ -540,8 +608,21 @@ fn for_each_read_in_dsts(dsts: &[AssignDestination], vid: air::VarId, f: &mut Re
540608
fn for_each_read_in_stmt(stmt: &Statement, vid: air::VarId, f: &mut ReadVisitor) {
541609
match stmt {
542610
Statement::Assign(a) => {
543-
for_each_read_in_expr(&a.expr, vid, f);
544-
for_each_read_in_dsts(&a.dst, vid, f);
611+
// A masked write folds its retention read into the mask (see
612+
// `match_masked_write`), so count only `d`/`m`/dst, not that read. A
613+
// genuine read at the same index elsewhere is still a distinct
614+
// factor and counted.
615+
if a.dst.len() == 1
616+
&& a.dst[0].id == vid
617+
&& let Some((d, m)) = match_masked_write(vid, &a.dst[0].index, &a.expr)
618+
{
619+
for_each_read_in_expr(d, vid, f);
620+
for_each_read_in_expr(m, vid, f);
621+
for_each_read_in_dsts(&a.dst, vid, f);
622+
} else {
623+
for_each_read_in_expr(&a.expr, vid, f);
624+
for_each_read_in_dsts(&a.dst, vid, f);
625+
}
545626
}
546627
Statement::If(i) => {
547628
for_each_read_in_expr(&i.cond, vid, f);

crates/synthesizer/src/conv/statement.rs

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ fn process_statement(
3030
) -> Result<(), SynthesizerError> {
3131
match stmt {
3232
Statement::Assign(a) => {
33+
// Byte-write-enable fold: record a masked RMW as one masked write
34+
// port (see `ram::match_masked_write`), not a write + retention read.
35+
if a.dst.len() == 1 && ctx.ram_vars.contains_key(&a.dst[0].id) {
36+
let dst = &a.dst[0];
37+
if let Some((d_expr, m_expr)) = ram::match_masked_write(dst.id, &dst.index, &a.expr)
38+
{
39+
return record_masked_ram_write(ctx, dst, d_expr, m_expr, current);
40+
}
41+
}
3342
if a.dst.len() > 1 {
3443
// Concat-LHS `{d, e, ...} = a` — slice MSB-first so dst[0]
3544
// gets the high bits.
@@ -133,7 +142,45 @@ fn record_ram_write(
133142
data.resize(cand.width, NET_CONST0);
134143
let enable = current_write_enable(ctx);
135144
if let Some(builder) = ctx.ram_builders.get_mut(&dst.id) {
136-
builder.writes.push(RamWritePort { addr, data, enable });
145+
builder.writes.push(RamWritePort {
146+
addr,
147+
data,
148+
enable,
149+
mask: None,
150+
});
151+
}
152+
Ok(())
153+
}
154+
155+
/// Records a masked RAM write as a single port carrying byte-enable `mask`; only
156+
/// `d`/`m` are synthesized, the retention read folds into the mask. Detection is
157+
/// [`ram::match_masked_write`], shared with port counting.
158+
fn record_masked_ram_write(
159+
ctx: &mut ConvContext,
160+
dst: &air::AssignDestination,
161+
d_expr: &air::Expression,
162+
m_expr: &air::Expression,
163+
current: &mut HashMap<air::VarId, Vec<NetId>>,
164+
) -> Result<(), SynthesizerError> {
165+
let cand = ctx.ram_vars[&dst.id];
166+
let idx_expr =
167+
dst.index.0.first().ok_or_else(|| {
168+
SynthesizerError::internal(format!("RAM write {} has no index", dst.id))
169+
})?;
170+
let idx_bits = arith::index_bits_for(cand.depth);
171+
let addr = synthesize_expr(ctx, idx_expr, current, idx_bits)?;
172+
let mut data = synthesize_expr(ctx, d_expr, current, cand.width)?;
173+
data.resize(cand.width, NET_CONST0);
174+
let mut mask = synthesize_expr(ctx, m_expr, current, cand.width)?;
175+
mask.resize(cand.width, NET_CONST0);
176+
let enable = current_write_enable(ctx);
177+
if let Some(builder) = ctx.ram_builders.get_mut(&dst.id) {
178+
builder.writes.push(RamWritePort {
179+
addr,
180+
data,
181+
enable,
182+
mask: Some(mask),
183+
});
137184
}
138185
Ok(())
139186
}

crates/synthesizer/src/ir.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,11 +239,17 @@ pub struct FfCell {
239239
/// One synchronous write port of a [`RamBlock`]. `data`/`addr`/`enable` are
240240
/// driven by surrounding logic; the write commits on the RAM's clock edge when
241241
/// `enable` is high. `addr`/`data` are LSB-first.
242+
///
243+
/// `mask`, when `Some(m)` (`m.len() == data.len()`), is a byte-/bit-write-enable:
244+
/// bit `i` is written where `m[i]` is high, retained otherwise. `None` is an
245+
/// unconditional whole-word write. Inferred from a read-modify-write (see
246+
/// `conv::ram::match_masked_write`); either way it is one write port.
242247
#[derive(Clone)]
243248
pub struct RamWritePort {
244249
pub addr: Vec<NetId>,
245250
pub data: Vec<NetId>,
246251
pub enable: NetId,
252+
pub mask: Option<Vec<NetId>>,
247253
}
248254

249255
/// One read port of a [`RamBlock`]. `data` nets are *outputs* — the RAM drives
@@ -293,6 +299,9 @@ impl GateModule {
293299
wp.addr.iter().for_each(|&n| f(n));
294300
wp.data.iter().for_each(|&n| f(n));
295301
f(wp.enable);
302+
if let Some(mask) = &wp.mask {
303+
mask.iter().for_each(|&n| f(n));
304+
}
296305
}
297306
for rp in &ram.read_ports {
298307
rp.addr.iter().for_each(|&n| f(n));
@@ -310,6 +319,9 @@ impl GateModule {
310319
wp.addr.iter_mut().for_each(&mut f);
311320
wp.data.iter_mut().for_each(&mut f);
312321
f(&mut wp.enable);
322+
if let Some(mask) = &mut wp.mask {
323+
mask.iter_mut().for_each(&mut f);
324+
}
313325
}
314326
for rp in &mut ram.read_ports {
315327
rp.addr.iter_mut().for_each(&mut f);

crates/synthesizer/tests/integration.rs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2647,6 +2647,108 @@ fn single_port_memory_infers_ram() {
26472647
assert!(result.area.memory > 0.0);
26482648
}
26492649

2650+
#[test]
2651+
fn byte_write_enable_folds_retention_read() {
2652+
// A read-modify-write `mem[a] = (mem[a] & ~m) | (d & m)` is a byte-write-
2653+
// enable: the retention read `mem[a]` supplies the "keep old bits" input and
2654+
// must fold into the write port's mask, NOT allocate a second read port. So
2655+
// this lookup-plus-RMW array is 1R1W (one lookup read + one masked write),
2656+
// not 2R1W (lookup read + retention read + write).
2657+
let code = r#"
2658+
module Bwe (
2659+
clk: input clock ,
2660+
we: input logic ,
2661+
waddr: input logic<6> ,
2662+
wdata: input logic<32> ,
2663+
wmask: input logic<32> ,
2664+
raddr: input logic<6> ,
2665+
rdata: output logic<32> ,
2666+
) {
2667+
var mem: logic<32> [64];
2668+
always_ff (clk) {
2669+
if we {
2670+
mem[waddr] = (mem[waddr] & ~wmask) | (wdata & wmask);
2671+
}
2672+
}
2673+
assign rdata = mem[raddr];
2674+
}
2675+
"#;
2676+
let (ir, top) = analyze(code, "Bwe");
2677+
let result = synthesize(&ir, top, Library::default()).expect("synthesize");
2678+
let m = &result.gate_ir.module;
2679+
2680+
assert_eq!(m.ram_blocks.len(), 1, "expected one inferred RAM block");
2681+
let ram = &m.ram_blocks[0];
2682+
assert_eq!(ram.depth, 64);
2683+
assert_eq!(ram.width, 32);
2684+
// The retention read folded into the mask → still 1 read (the lookup) + 1
2685+
// masked write. Without the fold this array would be 2R1W.
2686+
assert_eq!(
2687+
ram.read_ports.len(),
2688+
1,
2689+
"retention read must fold into the byte-enable, not a 2nd read port"
2690+
);
2691+
assert_eq!(ram.write_ports.len(), 1);
2692+
let wp = &ram.write_ports[0];
2693+
let mask = wp
2694+
.mask
2695+
.as_ref()
2696+
.expect("masked write must carry a byte-enable");
2697+
assert_eq!(mask.len(), ram.width, "mask width must equal data width");
2698+
assert_eq!(m.ffs.len(), 0, "RAM array must not expand to flip-flops");
2699+
}
2700+
2701+
#[test]
2702+
fn byte_write_enable_frees_a_read_port_near_the_limit() {
2703+
// RAM_MAX_READ_PORTS is 16. With 16 distinct lookup reads the array is
2704+
// exactly at the limit; a masked write's retention read would be a 17th
2705+
// distinct read address and push it over, collapsing the array to
2706+
// flip-flops. Because the retention read folds into the byte-enable, the
2707+
// count stays 16 and the array still infers as a 16R1W RAM.
2708+
let n = 16;
2709+
let mut raddr_ports = String::new();
2710+
let mut read_expr = String::new();
2711+
for i in 0..n {
2712+
raddr_ports.push_str(&format!(" ra{i}: input logic<6> ,\n"));
2713+
if i > 0 {
2714+
read_expr.push_str(" ^ ");
2715+
}
2716+
read_expr.push_str(&format!("mem[ra{i}]"));
2717+
}
2718+
let code = format!(
2719+
r#"
2720+
module BweLimit (
2721+
clk: input clock ,
2722+
we: input logic ,
2723+
waddr: input logic<6> ,
2724+
wdata: input logic<32> ,
2725+
wmask: input logic<32> ,
2726+
{raddr_ports} rdata: output logic<32> ,
2727+
) {{
2728+
var mem: logic<32> [64];
2729+
always_ff (clk) {{
2730+
if we {{
2731+
mem[waddr] = (mem[waddr] & ~wmask) | (wdata & wmask);
2732+
}}
2733+
}}
2734+
assign rdata = {read_expr};
2735+
}}
2736+
"#
2737+
);
2738+
let (ir, top) = analyze(&code, "BweLimit");
2739+
let result = synthesize(&ir, top, Library::default()).expect("synthesize");
2740+
let m = &result.gate_ir.module;
2741+
assert_eq!(
2742+
m.ram_blocks.len(),
2743+
1,
2744+
"16 lookups + a folded retention read must still infer as RAM, not flops"
2745+
);
2746+
assert_eq!(m.ram_blocks[0].read_ports.len(), 16);
2747+
assert_eq!(m.ram_blocks[0].write_ports.len(), 1);
2748+
assert!(m.ram_blocks[0].write_ports[0].mask.is_some());
2749+
assert_eq!(m.ffs.len(), 0, "array must not expand to flip-flops");
2750+
}
2751+
26502752
#[test]
26512753
fn reset_array_stays_flip_flops_by_default() {
26522754
// Real SRAM has no reset, so a reset array is always kept as flip-flops; an
@@ -3116,6 +3218,7 @@ fn ram_block_area_timing_power_and_dump() {
31163218
addr: vec![0; ADDR_W],
31173219
data: vec![0; WIDTH],
31183220
enable: 0,
3221+
mask: None,
31193222
}],
31203223
}],
31213224
};

0 commit comments

Comments
 (0)