Browse Source

tests for safsim and wip ppsfp implementation

main
stefan 2 weeks ago
parent
commit
8ef19cf053
  1. 8
      main.py
  2. 85
      src/fsim/ppsfp.py
  3. 203
      tests/test_safsim.py

8
main.py

@ -12,13 +12,17 @@ from kyupy.techlib import techlib_by_name, KYUPY
from fsim.static import LineRoles, FaultSet from fsim.static import LineRoles, FaultSet
from fsim.simple import SAFSimSimple from fsim.simple import SAFSimSimple
from fsim.incremental import SAFSimIncremental from fsim.incremental import SAFSimIncremental
from fsim.ppsfp import SAFSimPPSFP
def main(): def main():
algorithms = {'simple': SAFSimSimple, 'incr': SAFSimIncremental, 'ppsfp': SAFSimPPSFP}
parser = argparse.ArgumentParser(description='A basic stuck-at fault simulator.') parser = argparse.ArgumentParser(description='A basic stuck-at fault simulator.')
parser.add_argument('-t', '--tlib', default='SKY130', help=f'Techlib for verilog circuit. Default: SKY130, available: {sorted(techlib_by_name.keys())}.') parser.add_argument('-t', '--tlib', default='SKY130', help=f'Techlib for verilog circuit. Default: SKY130, available: {sorted(techlib_by_name.keys())}.')
parser.add_argument('-p', '--patterns', default='1024', help='Pattern file or number of random patterns to simulate. Default: 1024.') parser.add_argument('-p', '--patterns', default='1024', help='Pattern file or number of random patterns to simulate. Default: 1024.')
parser.add_argument('-a', '--algorithm', default='simple', help=f'Fault simulation algorithm. Default: simple, available: {sorted(algorithms.keys())}.')
parser.add_argument('-o', '--output', default=None, help='') parser.add_argument('-o', '--output', default=None, help='')
parser.add_argument('--seed', type=int, default=42, help='Random seed for reproducibility. Default: 42.') parser.add_argument('--seed', type=int, default=42, help='Random seed for reproducibility. Default: 42.')
parser.add_argument('circuit', help='Gate-level verilog, bench, or nix package to import. See available packages: "nix flake show github:s-holst/benchmark-circuits".') parser.add_argument('circuit', help='Gate-level verilog, bench, or nix package to import. See available packages: "nix flake show github:s-holst/benchmark-circuits".')
@ -76,8 +80,8 @@ def main():
saf_collapsed = np.array(list(fs.saf_equiv_classes.keys()), dtype=np.uint32) saf_collapsed = np.array(list(fs.saf_equiv_classes.keys()), dtype=np.uint32)
rng.shuffle(saf_collapsed) rng.shuffle(saf_collapsed)
safsim = SAFSimSimple(c_resolved, min(patterns.shape[1], 10240)) safsim = algorithms[args.algorithm](c_resolved, min(patterns.shape[1], 10240))
log.info(f'{safsim.sim=}') log.info(f'{args.algorithm} {safsim.sim=}')
fclasses = safsim.classify_faults(saf_collapsed, patterns) fclasses = safsim.classify_faults(saf_collapsed, patterns)

85
src/fsim/ppsfp.py

@ -0,0 +1,85 @@
import time
from collections.abc import Collection
import numpy as np
from kyupy import log, batchrange, cdiv, Timers, logic
from kyupy.circuit import Circuit, Node
from kyupy.logic_sim import LogicSim2V
from kyupy.techlib import KYUPY
class SAFSimPPSFP:
"""A stuck-at fault simulator that uses explicit simulations only at roots of fan-out free regions.
"""
def __init__(self, circuit_resolved: Circuit, batch_size: int):
self.sim = LogicSim2V(circuit_resolved, sims=batch_size)
self.timers = Timers()
self.ffr_stem2idx = {}
self.lines2ffr_stem: list[Node|None] = [None] * len(circuit_resolved.lines)
for stem, nodes in circuit_resolved.fanout_free_regions(KYUPY):
self.ffr_stem2idx[stem] = len(self.ffr_stem2idx)
for n in nodes + [stem]:
for il in n.ins.without_nones():
self.lines2ffr_stem[il.index] = stem
def classify_faults(self, faults: Collection[int], patterns: np.ndarray):
with self.timers['startup']:
self.sim.c_prop() # trigger jit
c_golden = np.zeros_like(self.sim.c)
fclass_DS = set()
nbatches = cdiv(patterns.shape[1], self.sim.sims)
nfaults = len(faults)
ffr_obs = np.zeros((len(self.ffr_stem2idx), self.sim.c.shape[2]), dtype=np.uint8)
ffr_obs_valid = np.zeros(len(self.ffr_stem2idx), dtype=np.uint8)
obs_mask = np.packbits(np.full(patterns.shape[1], 1, dtype=np.uint8), bitorder='little')
fault_to_stem_obs = obs_mask.copy()
fault_act = obs_mask.copy()
with self.timers['sim'], log.progress() as p:
for bidx, (bo, bs) in enumerate(batchrange(patterns.shape[1], self.sim.sims)):
self.sim.s_assign[:, :bs] = patterns[:, bo:bo+bs]
self.sim.s_to_c()
self.sim.c_dirty[...] = 1
with self.timers['sim_full_prop']:
self.sim.c_prop()
c_golden[...] = self.sim.c
c_golden_poppo = c_golden[self.sim.poppo_c_locs]
ffr_obs_valid[...] = 0
for fidx, fault in enumerate(faults):
fault_site = fault//2
fault_polarity = fault&1
ffr_stem = self.lines2ffr_stem[fault_site]
assert ffr_stem is not None
ffr_idx = self.ffr_stem2idx[ffr_stem]
# compute FFR observability vector ffr_obs[ffr_idx] if necessary
if not ffr_obs_valid[ffr_idx]:
if len(ffr_stem.outs) > 1:
self.sim.c_dirty[...] = 0
with self.timers['sim_ffr_prop']:
self.sim.c_prop(fault_line=ffr_stem.ins[0].index, fault_model=2)
with self.timers['sim_ffr_obs']:
ffr_obs[ffr_idx] = np.bitwise_or.reduce(c_golden_poppo ^ self.sim.c[self.sim.poppo_c_locs], axis=0) & obs_mask
with self.timers['sim_ffr_reset']:
self.sim.c[...] = c_golden # clear fault
else: # primary output, completely observable
ffr_obs[ffr_idx] = obs_mask
ffr_obs_valid[ffr_idx] = 1
# FIXME: compute observability of fault location at FFR stem
fault_to_stem_obs[...] = obs_mask
# compute fault activation
fault_act[...] = self.sim.c[self.sim.c_locs[fault_site]] & obs_mask
if fault_polarity: # stuck-at 1?
fault_act ^= obs_mask # activated iff signal is 0
if np.bitwise_or.reduce(ffr_obs[ffr_idx] & fault_to_stem_obs & fault_act) != 0:
fclass_DS.add(fault)
p.update(((fidx+1) / nfaults) * ((bidx+1) / nbatches))
return {'DS': fclass_DS, 'NO': set(faults) - fclass_DS}

203
tests/test_safsim.py

@ -0,0 +1,203 @@
"""Unit tests for the stuck-at fault simulators (the SAF* classes).
Each fault is identified by ``line.index * 2 + polarity`` where polarity 0 is
stuck-at-0 and polarity 1 is stuck-at-1. ``classify_faults`` returns a dict
with the detected-by-simulation set under ``'DS'`` and the not-observed set
under ``'NO'``.
The three simulators are exercised side-by-side:
* ``SAFSimSimple`` - brute force, exact.
* ``SAFSimIncremental`` - incremental cone re-simulation, exact.
* ``SAFSimPPSFP`` - FFR-based, deliberately *optimistic*: its stem
observability is hard-wired to "always observable" (see the FIXME in
``ppsfp.py``), so its DS set is a super-set of the exact DS set.
"""
import itertools
import numpy as np
import pytest
from kyupy import bench, logic
from kyupy.techlib import KYUPY
from fsim.static import FaultSet
from fsim.simple import SAFSimSimple
from fsim.incremental import SAFSimIncremental
from fsim.ppsfp import SAFSimPPSFP
# The two exact simulators must always agree; PPSFP only over-approximates.
ALL_ALGS = [SAFSimSimple, SAFSimIncremental, SAFSimPPSFP]
def make_patterns(*specs):
"""Build a 2D pattern array from per-pattern strings via ``logic.mvarray``.
Each string holds one character per s-node, ordered as the inputs and
outputs are defined in the netlist (output positions are don't-cares here,
overwritten by simulation). The result has shape ``(n_s_nodes, n_patterns)``
as expected by the simulators; a lone pattern is reshaped to a single column.
"""
pat = logic.mvarray(*specs)
return pat if pat.ndim == 2 else pat.reshape(-1, 1)
def classify(alg, circuit, faults, patterns):
# PPSFP requires the batch size to match the pattern count (as main.py does).
sim = alg(circuit, patterns.shape[1])
return sim.classify_faults(list(faults), patterns)
def saf(line, polarity):
return line.index * 2 + polarity
# --------------------------------------------------------------------------- #
# Inline single-gate circuits with hand-computed expectations.
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize('alg', ALL_ALGS)
def test_inv_single_pattern(alg):
# s-nodes are 'i','o'. i=0 => golden o=1. Detect faults that flip o off 1.
c = bench.parse('input(i) output(o) o=INV(i)')
oline = c.forks['o'].ins[0]
iline = oline.driver.ins[0]
pat = make_patterns('0-') # i=0 (o position is a don't-care)
faults = [saf(oline, 0), saf(oline, 1), saf(iline, 0), saf(iline, 1)]
r = classify(alg, c, faults, pat)
# o s-a-0 forces 0 (!=1) -> detected; i s-a-1 forces i=1 -> o=0 -> detected.
assert r['DS'] == {saf(oline, 0), saf(iline, 1)}
# o s-a-1 already matches golden; i s-a-0 keeps i=0 -> nothing changes.
assert r['NO'] == {saf(oline, 1), saf(iline, 0)}
@pytest.mark.parametrize('alg', ALL_ALGS)
def test_inv_both_polarities_detected(alg):
# Applying both i=0 and i=1 sensitises every fault of the inverter.
c = bench.parse('input(i) output(o) o=INV(i)')
oline = c.forks['o'].ins[0]
iline = oline.driver.ins[0]
pat = make_patterns('00', '10') # i=0 and i=1
faults = [saf(oline, 0), saf(oline, 1), saf(iline, 0), saf(iline, 1)]
r = classify(alg, c, faults, pat)
assert r['DS'] == set(faults)
assert r['NO'] == set()
@pytest.mark.parametrize('alg', ALL_ALGS)
def test_and2_output(alg):
# s-nodes are 'i0','i1','o'.
c = bench.parse('input(i0,i1) output(o) o=AND2(i0,i1)')
oline = c.forks['o'].ins[0]
o_sa0, o_sa1 = saf(oline, 0), saf(oline, 1)
# i0=i1=1 => golden o=1: only o s-a-0 is observable.
r = classify(alg, c, [o_sa0, o_sa1], make_patterns('110'))
assert r['DS'] == {o_sa0}
assert r['NO'] == {o_sa1}
# i0=0,i1=1 => golden o=0: only o s-a-1 is observable.
r = classify(alg, c, [o_sa0, o_sa1], make_patterns('010'))
assert r['DS'] == {o_sa1}
assert r['NO'] == {o_sa0}
@pytest.mark.parametrize('alg', ALL_ALGS)
def test_two_level_nand_propagation(alg):
# o = NAND2(NAND2(a,b), c); s-nodes 'a','b','c','o'. a=b=c=1: n=0, o=1.
c = bench.parse('input(a,b,c) output(o) n=NAND2(a,b) o=NAND2(n,c)')
nline = c.forks['n'].ins[0]
oline = c.forks['o'].ins[0]
faults = [saf(nline, 0), saf(nline, 1), saf(oline, 0), saf(oline, 1)]
r = classify(alg, c, faults, make_patterns('1110'))
# n already 0 (s-a-0 invisible); n s-a-1 -> o=0; o s-a-0 visible; o already 1.
assert r['DS'] == {saf(nline, 1), saf(oline, 0)}
assert r['NO'] == {saf(nline, 0), saf(oline, 1)}
# --------------------------------------------------------------------------- #
# c17 fixture: small, fully testable combinational benchmark.
# --------------------------------------------------------------------------- #
def _c17_exhaustive_patterns():
"""All 2**5 input combinations of c17.
s-nodes are inputs 1,2,3,6,7 then outputs 22,23 (netlist order); the two
output positions are don't-cares appended after the five input bits.
"""
return make_patterns(*[''.join(combo) + '--'
for combo in itertools.product('01', repeat=5)])
@pytest.mark.parametrize('alg', ALL_ALGS)
def test_c17_fully_detected(alg, c17_bench, c17_resolved):
# c17 has no redundant faults, so exhaustive patterns detect every fault.
fs = FaultSet(c17_bench, KYUPY, c17_resolved)
faults = list(fs.saf_equiv_classes.keys())
pat = _c17_exhaustive_patterns()
r = classify(alg, c17_resolved, faults, pat)
assert r['DS'] == set(faults)
assert r['NO'] == set()
@pytest.mark.parametrize('alg', ALL_ALGS)
def test_c17_output_fault_class(alg, c17_resolved):
# All-zero inputs: output 22 settles to 0, so only its s-a-1 is observable.
o22 = c17_resolved.forks['22'].ins[0]
pat = make_patterns('00000--') # all inputs 0
r = classify(alg, c17_resolved, [saf(o22, 0), saf(o22, 1)], pat)
assert r['DS'] == {saf(o22, 1)}
assert r['NO'] == {saf(o22, 0)}
# --------------------------------------------------------------------------- #
# s27 fixture: exposes the exact-vs-optimistic difference between simulators.
# --------------------------------------------------------------------------- #
def _s27_setup(s27_bench, s27_resolved):
fs = FaultSet(s27_bench, KYUPY, s27_resolved)
faults = list(fs.saf_equiv_classes.keys())
rng = np.random.default_rng(1)
pat = rng.choice([logic.ZERO, logic.ONE],
size=(len(s27_resolved.s_nodes(KYUPY)), 64)).astype(np.uint8)
return faults, pat
def test_s27_exact_simulators_agree(s27_bench, s27_resolved):
faults, pat = _s27_setup(s27_bench, s27_resolved)
assert len(faults) == 32 # collapsed fault count, see test_fault_set.test_s27
simple = classify(SAFSimSimple, s27_resolved, faults, pat)
incr = classify(SAFSimIncremental, s27_resolved, faults, pat)
# The two exact simulators must classify every fault identically.
assert simple['DS'] == incr['DS']
assert simple['NO'] == incr['NO']
# This deterministic pattern set leaves exactly one fault unobserved.
assert len(simple['DS']) == 31
assert len(simple['NO']) == 1
def test_s27_ppsfp_over_approximates(s27_bench, s27_resolved):
faults, pat = _s27_setup(s27_bench, s27_resolved)
exact = classify(SAFSimSimple, s27_resolved, faults, pat)
ppsfp = classify(SAFSimPPSFP, s27_resolved, faults, pat)
# PPSFP's optimistic stem observability can only add detections, never drop.
assert exact['DS'] <= ppsfp['DS']
# Here it reports the lone exact-NO fault as detected too.
assert ppsfp['DS'] == exact['DS'] | exact['NO']
@pytest.mark.parametrize('alg', ALL_ALGS)
def test_ppsfp_multibranch_stem_observability(alg):
# Stem `s` fans out to two AND gates; with sel1=0,sel2=1 the stem is
# observable only through the o2 branch (the stem's *second* fanout line).
c = bench.parse('input(a,b,sel1,sel2) output(o1,o2) '
's=NAND2(a,b) o1=AND2(s,sel1) o2=AND2(s,sel2)')
a_sa0 = saf(c.forks['s'].ins[0].driver.ins[0], 0)
# a=1,b=1,sel1=0,sel2=1: a s-a-0 flips s, propagating through o2 only.
pat = make_patterns('1101--')
r = classify(alg, c, [a_sa0], pat)
assert r['DS'] == {a_sa0}
Loading…
Cancel
Save