diff --git a/.gitignore b/.gitignore index 8de4562684..31b79ea439 100644 --- a/.gitignore +++ b/.gitignore @@ -96,3 +96,4 @@ docs/mdbook-drawio # Scala .metals +.scala-build diff --git a/bittide-instances/bittide-instances.cabal b/bittide-instances/bittide-instances.cabal index 715e69ff8b..86d096541c 100644 --- a/bittide-instances/bittide-instances.cabal +++ b/bittide-instances/bittide-instances.cabal @@ -203,6 +203,7 @@ library Bittide.Instances.Hitl.Utils.Program Bittide.Instances.Hitl.Utils.Serial Bittide.Instances.Hitl.Utils.Ugn + Bittide.Instances.Hitl.Utils.UgnGrooming Bittide.Instances.Hitl.Utils.Usb Bittide.Instances.Hitl.Utils.Utils Bittide.Instances.Hitl.Utils.Vivado @@ -325,6 +326,7 @@ test-suite unittests Tests.Bittide.Instances.Hitl.Utils.MemoryMap Tests.Bittide.Instances.Hitl.Utils.OpenOcd Tests.Bittide.Instances.Hitl.Utils.Serial + Tests.Bittide.Instances.Hitl.Utils.UgnGrooming Tests.ClockControlWb Wishbone.AddressableBytesWb Wishbone.Axi @@ -349,6 +351,7 @@ test-suite unittests clash-prelude-hedgehog, clash-protocols-memmap, clash-vexriscv, + containers, directory, extra, filepath, diff --git a/bittide-instances/src/Bittide/Instances/Hitl/GenericDemo/Core.hs b/bittide-instances/src/Bittide/Instances/Hitl/GenericDemo/Core.hs index d1378e3fda..4d6c1dc509 100644 --- a/bittide-instances/src/Bittide/Instances/Hitl/GenericDemo/Core.hs +++ b/bittide-instances/src/Bittide/Instances/Hitl/GenericDemo/Core.hs @@ -59,6 +59,7 @@ import Protocols import Bittide.CaptureUgn (captureUgns, sendUgn) import Bittide.ClockControl (SpeedChange) import Bittide.ClockControl.CallistoSw (SwcccInternalBusses, callistoSwClockControlC) +import Bittide.ClockControl.Ugn.Corrections (correctionsWb) import Bittide.DoubleBufferedRam (wbStorage) import Bittide.ElasticBuffer (fromData, xilinxElasticBufferWb) import Bittide.Extra.Maybe (toMaybe) @@ -76,6 +77,7 @@ import Bittide.ProcessingElement ( import Bittide.RingBuffer (receiveRingBuffer, transmitRingBuffer) import Bittide.SharedTypes (Bitbone, BitboneMm) import Bittide.Sync (Sync) +import Bittide.TimedReset (timedResetWb) import Bittide.Wishbone (readDnaPortE2WbWorker, timeWb, uartBytes, uartInterfaceWb) import Clash.Class.BitPackC (ByteOrder) import Clash.Cores.Xilinx (withXilinx) @@ -97,7 +99,7 @@ import qualified Protocols.Vec as Vec -- | The number of CPUs in 'core' type InternalCpuCount = 2 -type FifoSize = 5 -- = 2^5 = 32 +type FifoSize = 6 -- = 2^6 = 64 {- Internal busses: - Instruction memory @@ -105,8 +107,10 @@ type FifoSize = 5 -- = 2^5 = 32 - `timeWb` - DNA - UART + - UGN grooming corrections + - application reset (TimedReset) -} -type NmuInternalBusses = 3 + PeInternalBusses +type NmuInternalBusses = 5 + PeInternalBusses {- Busses per link: - Elastic buffer @@ -144,6 +148,10 @@ type UserCoreCircuit userCoreBusses muRemBusWidth = Enable Bittide -> Signal Bittide (Unsigned 64) -> Signal Bittide (Maybe (BitVector 96)) -> + -- Application reset, released by the management unit's 'timedResetWb' at a + -- chosen local-counter cycle (the UGN-grooming relabel). Demos that have an + -- application counter gate it on this reset; others may ignore it. + Reset Bittide -> Circuit ( Vec userCoreBusses (ToConstBwd Mm, Bitbone Bittide muRemBusWidth) , "RXS2_RAW" ::: CSignal Bittide (Vec LinkCount (BitVector 64)) @@ -201,6 +209,7 @@ managementUnit :: Circuit (ToConstBwd Mm.Mm, Jtag dom) ( Df dom (BitVector 8) + , "APP_RESET" ::: Reset dom , Vec (NmuExternalBusses userCoreBusses) ( ToConstBwd Mm.Mm @@ -211,16 +220,24 @@ managementUnit externalCounter maybeDna = circuit $ \(mm, jtag) -> do -- Core and interconnect allBusses <- processingElement NoDumpVcd muConfig -< (mm, jtag) - ([timeBus, uartBus, dnaBus], restBusses) <- Vec.split -< allBusses + ([timeBus, uartBus, dnaBus, correctionsBus, timedResetBus], restBusses) <- + Vec.split -< allBusses -- Peripherals _localCounter <- timeWb (Just externalCounter) -< timeBus (uartOut, _uartStatus) <- uartInterfaceWb d16 d16 uartBytes -< (uartBus, Fwd (pure Nothing)) readDnaPortE2WbWorker maybeDna -< dnaBus + -- Host-written UGN grooming corrections, polled and applied by the MU CPU. + correctionsWb (SNat @LinkCount) -< correctionsBus + -- Application reset for the demo's user core: the management unit chooses the + -- local-counter cycle at which the application leaves reset (the UGN-grooming + -- relabel). The reset lives here, in the layer around the user core; the + -- application counter it gates lives in the user core itself. + appReset <- timedResetWb externalCounter -< timedResetBus -- Output - idC -< (uartOut, restBusses) + idC -< (uartOut, appReset, restBusses) core :: forall userCoreBusses ringBufferDepth. @@ -262,7 +279,7 @@ core bufferDepth mkUserCore (refClk, refRst) (bitClk, bitRst, bitEna) rxClocks r localCounter = register bitClk bitRst bitEna 0 (localCounter + 1) -- Start management unit - (muUartBytesBittide, muWbAll) <- + (muUartBytesBittide, Fwd appReset, muWbAll) <- withBittideClockResetEnable managementUnit localCounter maybeDna -< (muMm, muJtag) (ebWbs, muWbs1) <- Vec.split -< muWbAll (rxBufferBusses, muWbs2) <- Vec.split -< muWbs1 @@ -329,7 +346,7 @@ core bufferDepth mkUserCore (refClk, refRst) (bitClk, bitRst, bitEna) rxClocks r -- Start user core: post-handshake stage drives the GTH-TX wire. Fwd txsOut <- - mkUserCore bitClk bitRst bitEna localCounter maybeDna + mkUserCore bitClk bitRst bitEna localCounter maybeDna appReset -< ( extraMuBusses , Fwd (bundle rxs2Raw) , Fwd (bundle handshakesOut.toNeighbors) diff --git a/bittide-instances/src/Bittide/Instances/Hitl/SoftUgnDemo/UserCore.hs b/bittide-instances/src/Bittide/Instances/Hitl/SoftUgnDemo/UserCore.hs index ce1acd0f6d..9ca3ab79ce 100644 --- a/bittide-instances/src/Bittide/Instances/Hitl/SoftUgnDemo/UserCore.hs +++ b/bittide-instances/src/Bittide/Instances/Hitl/SoftUgnDemo/UserCore.hs @@ -26,7 +26,7 @@ ringBufferDepth :: SNat RingBufferDepth ringBufferDepth = SNat mkUserCore :: UserCoreCircuit UserCoreBusses (NmuRemBusWidth UserCoreBusses) -mkUserCore _bitClk _bitRst _bitEna _localCounter _maybeDna = +mkUserCore _bitClk _bitRst _bitEna _localCounter _maybeDna _appReset = circuit $ \(emptyUserCoreBusses, _rxs2Raw, handshakeOut) -> do [] <- idC -< emptyUserCoreBusses idC -< handshakeOut diff --git a/bittide-instances/src/Bittide/Instances/Hitl/Utils/Gdb.hs b/bittide-instances/src/Bittide/Instances/Hitl/Utils/Gdb.hs index 3b574491b7..d035c0926b 100644 --- a/bittide-instances/src/Bittide/Instances/Hitl/Utils/Gdb.hs +++ b/bittide-instances/src/Bittide/Instances/Hitl/Utils/Gdb.hs @@ -6,11 +6,20 @@ module Bittide.Instances.Hitl.Utils.Gdb where import Prelude +import Clash.Prelude (Signed, Unsigned, Vec, checkedFromIntegral, toList) + import Bittide.Hitl (DeviceInfo (..)) +import Bittide.Instances.Hitl.Setup (LinkCount) import Bittide.Instances.Hitl.Utils.Driver (getTargetIndex) +import Bittide.Instances.Hitl.Utils.MemoryMap (getPathAddress) import Bittide.Instances.Hitl.Utils.OpenOcd (TapInfo (..)) +import Bittide.Wishbone (TimeCmd (Capture)) +import Control.Monad (forM_) +import GHC.Stack (HasCallStack) import Gdb (Gdb) import Project.FilePath +import Project.Handle (expectRight) +import Protocols.MemoryMap (MemoryMap) import System.FilePath (()) import Vivado.Tcl (HwTarget) @@ -26,3 +35,75 @@ initGdb hitlDir binName gdb tapInfo (hwT, _d) = do Gdb.setTimeout gdb Nothing Gdb.runCommand gdb "echo connected to target device" pure () + +-- * GDB-based component HAL accessors + +-- +-- Read/write a component's registers over GDB, addressed through a 'MemoryMap'. Generic +-- across demos (the memory map is a parameter). All require the CPU to be halted. + +-- | Read the current local-counter value from a device's @Timer@ peripheral. +readCurrentTime :: (HasCallStack) => MemoryMap -> (HwTarget, DeviceInfo) -> Gdb -> IO (Unsigned 64) +readCurrentTime mm (_, d) gdb = do + putStrLn $ "Getting current time from device " <> d.deviceId + commandAddress <- expectRight $ getPathAddress mm ["0", "Timer", "command"] + scratchAddress <- expectRight $ getPathAddress mm ["0", "Timer", "scratchpad"] + Gdb.writeLe gdb commandAddress Capture + Gdb.readLe gdb scratchAddress + +{- | Write a node's reset-release cycle to its @TimedReset@ peripheral. Once the local +counter exceeds this value the node's application (and its application counter) leave +reset. +-} +writeReleaseCycle :: (HasCallStack) => MemoryMap -> Gdb -> Unsigned 64 -> IO () +writeReleaseCycle mm gdb releaseCycle = do + addr <- expectRight $ getPathAddress mm ["0", "TimedReset", "release_cycle"] + Gdb.writeLe gdb addr releaseCycle + +{- | Write the per-link frame corrections and set the @valid@ flag on the +@UgnCorrections@ peripheral. Resuming the CPU makes the management unit apply the +corrections to its elastic buffers. +-} +writeCorrections :: (HasCallStack) => MemoryMap -> Gdb -> Vec LinkCount (Signed 64) -> IO () +writeCorrections mm gdb corrections = do + correctionsAddr <- expectRight $ getPathAddress mm ["0", "UgnCorrections", "corrections"] + validAddr <- expectRight $ getPathAddress mm ["0", "UgnCorrections", "valid"] + Gdb.writeLe gdb correctionsAddr corrections + Gdb.writeLe gdb validAddr True + +{- | Read the per-link hardware UGN counter captures from the @CaptureUgns@ peripheral: +the @(localCounter, remoteCounter)@ pair per link, with @localCounter@ already adjusted +for the elastic-buffer frames added/removed since capture (@elastic_buffer_delta@). +-} +readHardwareUgns :: + (HasCallStack) => MemoryMap -> (HwTarget, DeviceInfo) -> Gdb -> IO [(Unsigned 64, Unsigned 64)] +readHardwareUgns mm (_, d) gdb = do + let + getUgnRegister reg = expectRight $ getPathAddress mm ["0", "CaptureUgns", reg] + + -- Adjust the local counter for the frames added/removed from the elastic + -- buffer after capturing the UGN. Leaves the remote counter untouched. + adjustLocalCounter :: (Unsigned 64, Unsigned 64, Signed 32) -> (Unsigned 64, Unsigned 64) + adjustLocalCounter (localCounter, remoteCounter, delta) = + (addSigned localCounter delta, remoteCounter) + where + addSigned :: Unsigned 64 -> Signed 32 -> Unsigned 64 + addSigned u s = checkedFromIntegral (toInteger u + toInteger s) + + putStrLn $ "Getting UGNs for device " <> d.deviceId + localCounters <- Gdb.readLe @(Vec LinkCount (Unsigned 64)) gdb =<< getUgnRegister "local_counter" + remoteCounters <- Gdb.readLe @(Vec LinkCount (Unsigned 64)) gdb =<< getUgnRegister "remote_counter" + deltas <- Gdb.readLe @(Vec LinkCount (Signed 32)) gdb =<< getUgnRegister "elastic_buffer_delta" + let ugnTriples = zip3 (toList localCounters) (toList remoteCounters) (toList deltas) + forM_ ugnTriples $ \triple -> putStrLn $ "Raw UGN triple: " <> show triple + pure $ adjustLocalCounter <$> ugnTriples + +{- | Read the relabeled application counter exposed by the user core's +@AppCounterReadback@ peripheral. With the application running, @localCounter - +app_counter@ equals the node's reset-release cycle, letting the host verify the relabel +landed on hardware. +-} +readAppCounter :: (HasCallStack) => MemoryMap -> (HwTarget, DeviceInfo) -> Gdb -> IO (Unsigned 64) +readAppCounter mm (_, _) gdb = do + addr <- expectRight $ getPathAddress mm ["0", "AppCounterReadback", "app_counter"] + Gdb.readLe gdb addr diff --git a/bittide-instances/src/Bittide/Instances/Hitl/Utils/Ugn.hs b/bittide-instances/src/Bittide/Instances/Hitl/Utils/Ugn.hs index 6618b0f761..4e9ccea6b0 100644 --- a/bittide-instances/src/Bittide/Instances/Hitl/Utils/Ugn.hs +++ b/bittide-instances/src/Bittide/Instances/Hitl/Utils/Ugn.hs @@ -211,6 +211,31 @@ timingOracleToUgnEdge oracle = , ugn = bitCoerce oracle.dstCounter - bitCoerce oracle.srcCounter } +{- | Turn a per-node, per-link table of UGN values (@λ@) into 'UgnEdge's keyed by DNA +node id. The outer vector is FPGA-indexed (the capturing / destination node); each +inner vector is link-indexed (matching 'knownLinkConfigs'). +-} +ugnEdges :: CP.Vec FpgaCount (CP.Vec LinkCount (Signed 64)) -> [UgnEdge] +ugnEdges lams = + L.concat (CP.toList (CP.imap nodeEdges lams)) + where + nodeEdges dstIndex links = CP.toList (CP.imap (edge dstIndex) links) + edge dstIndex port lam = + UgnEdge + { srcNode = indexToNodeId ((knownLinkConfigs CP.!! dstIndex) CP.!! port) + , srcPort = port + , dstNode = indexToNodeId dstIndex + , dstPort = port + , ugn = lam + } + +{- | As 'ugnEdges', but from raw @(localCounter, remoteCounter)@ captures: the UGN is +their signed difference @local - remote@. +-} +hardwareUgnEdges :: + CP.Vec FpgaCount (CP.Vec LinkCount (Unsigned 64, Unsigned 64)) -> [UgnEdge] +hardwareUgnEdges = ugnEdges . CP.map (CP.map (\(l, r) -> bitCoerce l - bitCoerce r)) + {- | Compare hardware and software UGN edges with detailed difference reporting. Performs edge-by-edge comparison and reports: diff --git a/bittide-instances/src/Bittide/Instances/Hitl/Utils/UgnGrooming.hs b/bittide-instances/src/Bittide/Instances/Hitl/Utils/UgnGrooming.hs new file mode 100644 index 0000000000..63070f2b79 --- /dev/null +++ b/bittide-instances/src/Bittide/Instances/Hitl/Utils/UgnGrooming.hs @@ -0,0 +1,140 @@ +-- SPDX-FileCopyrightText: 2026 Google LLC +-- +-- SPDX-License-Identifier: Apache-2.0 + +{- | +The application-facing UGN grooming layer for HITL: it adapts the generic grooming +algorithm ("Bittide.ClockControl.Ugn.Grooming", on "Bittide.Graph.Weighted") to the HITL +'UgnEdge' type and turns its result into a directly-applyable 'RelabelPlan'. + +Nodes are identified by their DNA-derived 'BitVector' 32 id (the @srcNode@ / @dstNode@ +fields of a 'UgnEdge'), so the graph is keyed directly on those ids. Edges are matched +between the measured @λ@ and stored @λ^safe@ snapshots by their @(srcNode, dstNode)@ pair +— unique because the demo topology is a complete graph (one link per ordered node pair). + +@Signed 64@ UGN values are widened to 'Integer' for the (overflow-prone) relaxation, and +the resulting reset offsets / frame counts are narrowed back with a bounds check. +-} +module Bittide.Instances.Hitl.Utils.UgnGrooming ( + ugnGraph, + safeMargin, + canonicalizeUgn, + RelabelPlan (..), + computeRelabel, +) where + +import Prelude + +import Clash.Prelude (BitVector, Signed, Vec, checkedFromIntegral) + +import Bittide.ClockControl.Ugn.Grooming (GroomResult (..), groomCorrection) +import Bittide.Graph.Weighted (Graph) +import Bittide.Instances.Hitl.Setup (FpgaCount, LinkCount, knownLinkConfigs) +import Bittide.Instances.Hitl.Utils.Ugn (UgnEdge (..), indexToNodeId) + +import qualified Bittide.Graph.Weighted as G +import qualified Clash.Prelude as CP +import qualified Data.Map.Strict as Map + +{- | Build a weighted graph from UGN edges, keyed on the DNA-derived node ids with +the (signed) UGN as the 'Integer' edge weight. +-} +ugnGraph :: [UgnEdge] -> Graph (BitVector 32) Integer +ugnGraph es = G.fromEdges [(e.srcNode, e.dstNode, toInteger e.ugn) | e <- es] + +{- | Add a safety margin @ε@ to every edge: @λ^safe = λ + ε@. Mirrors the +record-update style of 'Bittide.Instances.Hitl.Utils.Ugn.addLatencyEdge'. +-} +safeMargin :: Signed 64 -> [UgnEdge] -> [UgnEdge] +safeMargin eps = map (\e -> e{ugn = e.ugn + eps}) + +{- | Relabel UGN edges to their minimal non-negative form: the Bellman-Ford reduced +costs @λ_{i->j} + q_i - q_j@ (potentials @q@ from the super-source), which are all +@>= 0@ and leave every round-trip / cycle sum unchanged. + +This is a pure gauge change (a relabeling), so it represents the /same/ physical +system, but in a gauge where the UGNs are small and non-negative. Storing +@λ^safe@ in this gauge keeps the application's per-link UGNs (and hence its fixed +schedule) inside the working range a depth-bounded mux/elastic-buffer needs, while +still honouring the true /asymmetric/ cycle constraints (unlike a symmetric +midpoint, which is only a valid relabeling on an acyclic graph). + +If the edges are not physically allowed (a negative cycle), they are returned +unchanged. +-} +canonicalizeUgn :: [UgnEdge] -> [UgnEdge] +canonicalizeUgn es = + case G.potentials (ugnGraph es) of + Nothing -> es + Just q -> + [ e{ugn = checkedFromIntegral (toInteger e.ugn + qOf e.srcNode - qOf e.dstNode)} + | e <- es + ] + where + qOf n = Map.findWithDefault 0 n q + +{- | The result of grooming a measured boot onto a target: per-node reset offsets and +per-node, per-link frame corrections, both FPGA-/link-indexed and ready to apply. +-} +data RelabelPlan = RelabelPlan + { resetOffsets :: Vec FpgaCount (Signed 64) + {- ^ Per-node offset to add to the shared base to get its reset-release cycle. This + realises the relabel @q@: every node's application counter leaves reset in the + relabeled domain. Gauged so node 0's offset is 0. + -} + , corrections :: Vec FpgaCount (Vec LinkCount (Signed 64)) + {- ^ Per-node, per-link frame corrections: the frames the management unit inserts + (@> 0@) or removes (@< 0@) on each receiving link to reach @λ^safe@ after relabeling. + -} + } + deriving (Show) + +{- | Groom this boot's measured UGNs onto the target @λ^safe@ and produce an applyable +'RelabelPlan': per-node reset offsets (the relabel @q@) and per-link frame corrections. +Returns 'Left' (with the witnessing node ids) if the UGNs changed too much to fit under +@λ^safe@ (a negative cycle in the slack graph). + +Calls 'groomCorrection' (Bellman-Ford, per the UGN grooming slides) directly on the +measured / target 'ugnGraph's. The relabel @q@ is the per-node potential that fits this +boot's @λ@ under the stored @λ^safe@ (@λ + B^T q <= λ^safe@); the residual +@frames = λ^safe - (λ + B^T q)@ are the small, non-negative per-link corrections the +elastic buffers insert. So: + + * @resetOffsets_i = -(q_i - q_0)@ — the reset release that applies the relabel + (gauged so node 0 is 0); and + * @corrections@ — the per-receiving-link frames. Since 'groomCorrection' returns frames + keyed by @(srcNode, dstNode)@, the receiving @(fpga, port)@ is reconstructed via + 'knownLinkConfigs' (the same link map 'Bittide.Instances.Hitl.Utils.Ugn.ugnEdges' + uses). + +When @λ^safe@ is stored in a small non-negative gauge (see 'canonicalizeUgn'), the relabel +absorbs this boot's counter offsets so the groomed app-frame UGNs land back on @λ^safe@ — +small, non-negative, and identical every run, which is what makes a single fixed schedule +reusable. + +Edges are matched by @(srcNode, dstNode)@; pass the full measured + target graphs to +groom every link. +-} +computeRelabel :: + -- | Measured UGNs @λ@ + [UgnEdge] -> + -- | Target UGNs @λ^safe@ + [UgnEdge] -> + Either [BitVector 32] RelabelPlan +computeRelabel measured target = + case groomCorrection (ugnGraph measured) (ugnGraph target) of + Infeasible ns -> Left ns + Feasible{correction = q, framesToInsert} -> + Right + RelabelPlan + { resetOffsets = (\idx -> negate (qOf idx - qOf 0)) <$> CP.indicesI + , corrections = (\idx -> (\port -> frameOf idx port) <$> CP.indicesI) <$> CP.indicesI + } + where + qOf idx = checkedFromIntegral (Map.findWithDefault 0 (indexToNodeId idx) q) + frameByEdge = Map.fromList [((i, j), f) | (i, j, f) <- framesToInsert] + frameOf dstIdx port = + checkedFromIntegral (Map.findWithDefault 0 (srcNode, dstNode) frameByEdge) + where + srcNode = indexToNodeId ((knownLinkConfigs CP.!! dstIdx) CP.!! port) + dstNode = indexToNodeId dstIdx diff --git a/bittide-instances/src/Bittide/Instances/Hitl/WireDemo/Driver.hs b/bittide-instances/src/Bittide/Instances/Hitl/WireDemo/Driver.hs index 92cd643e30..58804cc776 100644 --- a/bittide-instances/src/Bittide/Instances/Hitl/WireDemo/Driver.hs +++ b/bittide-instances/src/Bittide/Instances/Hitl/WireDemo/Driver.hs @@ -10,23 +10,37 @@ module Bittide.Instances.Hitl.WireDemo.Driver where import Clash.Prelude import Bittide.ClockControl.Config (defCcConf) +import Bittide.Graph.Weighted (Graph) import Bittide.Hitl import Bittide.Instances.Domains (GthTx) -import Bittide.Instances.Hitl.Setup (FpgaCount, LinkCount, demoRigInfo, fpgaSetup) +import Bittide.Instances.Hitl.Setup (FpgaCount, demoRigInfo, fpgaSetup) import Bittide.Instances.Hitl.Utils.Driver -import Bittide.Instances.Hitl.Utils.Gdb (initGdb) +import Bittide.Instances.Hitl.Utils.Gdb ( + initGdb, + readAppCounter, + readCurrentTime, + readHardwareUgns, + writeCorrections, + writeReleaseCycle, + ) import Bittide.Instances.Hitl.Utils.MemoryMap (getPathAddress) import Bittide.Instances.Hitl.Utils.OpenOcd (parseBootTapInfo, parseTapInfo) import Bittide.Instances.Hitl.Utils.Serial (initSerial) +import Bittide.Instances.Hitl.Utils.Ugn (UgnEdge (..), hardwareUgnEdges, indexToNodeId) +import Bittide.Instances.Hitl.Utils.UgnGrooming ( + RelabelPlan (..), + canonicalizeUgn, + computeRelabel, + safeMargin, + ugnGraph, + ) import Bittide.Instances.Hitl.Utils.Usb (resetUsbDeviceByLocation) import Bittide.Instances.Hitl.Utils.Utils (dumpCcSamples) -import Bittide.Wishbone (TimeCmd (Capture)) import Control.Concurrent (threadDelay) import Control.Concurrent.Async (forConcurrently_, mapConcurrently_) import Control.Concurrent.Async.Extra (zipWithConcurrently, zipWithConcurrently3_) -import Control.Monad (forM_, unless) +import Control.Monad (forM, forM_, unless) import Control.Monad.IO.Class -import Data.Bifunctor (Bifunctor (bimap)) import Data.Maybe (fromJust) import Data.String.Interpolate (i) import Data.Vector.Internal.Check (HasCallStack) @@ -35,7 +49,6 @@ import Numeric (showHex) import Project.Chan import Project.FilePath import Project.Handle (assertEither, expectRight) -import Protocols.MemoryMap (MemoryMap) import System.Exit import System.FilePath import Vivado.Tcl (HwTarget) @@ -47,11 +60,16 @@ import qualified Bittide.Instances.Hitl.Utils.OpenOcd as Ocd import qualified Bittide.Instances.Hitl.WireDemo.MemoryMaps as MemoryMaps import qualified Clash.Sized.Vector as V import qualified Data.List as L -import qualified Data.Map as Map import qualified Gdb import qualified System.Timeout.Extra as T -type StartDelay = 10 -- seconds +{- | Seconds of headroom between reading the current time and the reset-release / +schedule point. This must comfortably exceed the wall-clock time it takes to +write all per-node timestamps + configs over GDB (~10s for 8 nodes), otherwise +a node's reset can release before it is fully configured (see the post-write +check in 'driver'). +-} +type StartDelay = 30 -- seconds {- | The delay in clock cycles between 2 PEs which is not accounted for by the `captureUgn` component. The wire demo schedule reasons about PE-to-PE timing, but @@ -62,6 +80,117 @@ to the PE-to-PE delay. internalDelay :: Int internalDelay = -4 +{- | Base cycle of the fixed schedule in the relabeled application-counter domain. +The application counter is 0 at the timed-reset release (tReset), so this small +base is a short head-start before the first node's PE fires. +-} +appScheduleBase :: Unsigned 64 +appScheduleBase = 1000 + +{- | Frames of safety margin added to the golden UGNs to form @λ^safe@ (@λ^safe = +golden + ε@). Each boot is groomed up to @λ^safe@, so @ε@ is the number of frames the +elastic buffer inserts on top of matching the golden latency; it must cover the small +boot-to-boot latency drift yet stay within the buffer's safe range. The elastic buffer +depth is 'Bittide.Instances.Hitl.GenericDemo.Core.FifoSize' bits (2^6 = 64 entries, +occupancy range ±32), so margin 5 plus per-link drift stays comfortably within range. +-} +marginFrames :: Signed 64 +marginFrames = 8 + +{- | Golden UGN graph: a full set of per-link UGNs (@λ = local - remote@) captured from a +passing CI boot of this rig, stored as a 'UgnEdge' list keyed by DNA-derived node id and +port. This is the stored reference every boot is groomed onto. + +The UGNs carry their boot-time counter offsets; 'lambdaSafe' relabels them into a +small, non-negative gauge before use. +-} +goldenUgns :: [UgnEdge] +{- FOURMOLU_DISABLE -} +goldenUgns = canonicalizeUgn + -- node 0 = 0x044164c5, node 1 = 0x05108285, node 2 = 0x2c808445, node 3 = 0x2c702305 + -- node 4 = 0x2581a285, node 5 = 0x2d01c345, node 6 = 0x04308185, node 7 = 0x2d20e405 + -- srcNode srcPort dstNode dstPort ugn + -- → node 0 + [ UgnEdge { srcNode = 0x2c702305, srcPort = 0, dstNode = 0x044164c5, dstPort = 0, ugn = 106220 } + , UgnEdge { srcNode = 0x2c808445, srcPort = 1, dstNode = 0x044164c5, dstPort = 1, ugn = 392207 } + , UgnEdge { srcNode = 0x2581a285, srcPort = 2, dstNode = 0x044164c5, dstPort = 2, ugn = 44472 } + , UgnEdge { srcNode = 0x2d01c345, srcPort = 3, dstNode = 0x044164c5, dstPort = 3, ugn = -137747 } + , UgnEdge { srcNode = 0x04308185, srcPort = 4, dstNode = 0x044164c5, dstPort = 4, ugn = 397184 } + , UgnEdge { srcNode = 0x2d20e405, srcPort = 5, dstNode = 0x044164c5, dstPort = 5, ugn = 63458 } + , UgnEdge { srcNode = 0x05108285, srcPort = 6, dstNode = 0x044164c5, dstPort = 6, ugn = 201560 } + -- → node 1 + , UgnEdge { srcNode = 0x2c808445, srcPort = 0, dstNode = 0x05108285, dstPort = 0, ugn = 189475 } + , UgnEdge { srcNode = 0x2c702305, srcPort = 1, dstNode = 0x05108285, dstPort = 1, ugn = -95287 } + , UgnEdge { srcNode = 0x2d01c345, srcPort = 2, dstNode = 0x05108285, dstPort = 2, ugn = -339254 } + , UgnEdge { srcNode = 0x04308185, srcPort = 3, dstNode = 0x05108285, dstPort = 3, ugn = 195678 } + , UgnEdge { srcNode = 0x2d20e405, srcPort = 4, dstNode = 0x05108285, dstPort = 4, ugn = -138049 } + , UgnEdge { srcNode = 0x2581a285, srcPort = 5, dstNode = 0x05108285, dstPort = 5, ugn = -157036 } + , UgnEdge { srcNode = 0x044164c5, srcPort = 6, dstNode = 0x05108285, dstPort = 6, ugn = -201453 } + -- → node 2 + , UgnEdge { srcNode = 0x05108285, srcPort = 0, dstNode = 0x2c808445, dstPort = 0, ugn = -189357 } + , UgnEdge { srcNode = 0x044164c5, srcPort = 1, dstNode = 0x2c808445, dstPort = 1, ugn = -390868 } + , UgnEdge { srcNode = 0x04308185, srcPort = 2, dstNode = 0x2c808445, dstPort = 2, ugn = 6262 } + , UgnEdge { srcNode = 0x2d20e405, srcPort = 3, dstNode = 0x2c808445, dstPort = 3, ugn = -327465 } + , UgnEdge { srcNode = 0x2581a285, srcPort = 4, dstNode = 0x2c808445, dstPort = 4, ugn = -346452 } + , UgnEdge { srcNode = 0x2d01c345, srcPort = 5, dstNode = 0x2c808445, dstPort = 5, ugn = -528671 } + , UgnEdge { srcNode = 0x2c702305, srcPort = 6, dstNode = 0x2c808445, dstPort = 6, ugn = -284703 } + -- → node 3 + , UgnEdge { srcNode = 0x044164c5, srcPort = 0, dstNode = 0x2c702305, dstPort = 0, ugn = -106114 } + , UgnEdge { srcNode = 0x05108285, srcPort = 1, dstNode = 0x2c702305, dstPort = 1, ugn = 95393 } + , UgnEdge { srcNode = 0x2d20e405, srcPort = 2, dstNode = 0x2c702305, dstPort = 2, ugn = -42708 } + , UgnEdge { srcNode = 0x2581a285, srcPort = 3, dstNode = 0x2c702305, dstPort = 3, ugn = -61695 } + , UgnEdge { srcNode = 0x2d01c345, srcPort = 4, dstNode = 0x2c702305, dstPort = 4, ugn = -243914 } + , UgnEdge { srcNode = 0x04308185, srcPort = 5, dstNode = 0x2c702305, dstPort = 5, ugn = 291015 } + , UgnEdge { srcNode = 0x2c808445, srcPort = 6, dstNode = 0x2c702305, dstPort = 6, ugn = 284810 } + -- → node 4 + , UgnEdge { srcNode = 0x2d20e405, srcPort = 0, dstNode = 0x2581a285, dstPort = 0, ugn = 19040 } + , UgnEdge { srcNode = 0x04308185, srcPort = 1, dstNode = 0x2581a285, dstPort = 1, ugn = 352767 } + , UgnEdge { srcNode = 0x044164c5, srcPort = 2, dstNode = 0x2581a285, dstPort = 2, ugn = -44364 } + , UgnEdge { srcNode = 0x2c702305, srcPort = 3, dstNode = 0x2581a285, dstPort = 3, ugn = 61802 } + , UgnEdge { srcNode = 0x2c808445, srcPort = 4, dstNode = 0x2581a285, dstPort = 4, ugn = 346558 } + , UgnEdge { srcNode = 0x05108285, srcPort = 5, dstNode = 0x2581a285, dstPort = 5, ugn = 157142 } + , UgnEdge { srcNode = 0x2d01c345, srcPort = 6, dstNode = 0x2581a285, dstPort = 6, ugn = -182165 } + -- → node 5 + , UgnEdge { srcNode = 0x04308185, srcPort = 0, dstNode = 0x2d01c345, dstPort = 0, ugn = 534982 } + , UgnEdge { srcNode = 0x2d20e405, srcPort = 1, dstNode = 0x2d01c345, dstPort = 1, ugn = 201259 } + , UgnEdge { srcNode = 0x05108285, srcPort = 2, dstNode = 0x2d01c345, dstPort = 2, ugn = 339361 } + , UgnEdge { srcNode = 0x044164c5, srcPort = 3, dstNode = 0x2d01c345, dstPort = 3, ugn = 137855 } + , UgnEdge { srcNode = 0x2c702305, srcPort = 4, dstNode = 0x2d01c345, dstPort = 4, ugn = 244021 } + , UgnEdge { srcNode = 0x2c808445, srcPort = 5, dstNode = 0x2d01c345, dstPort = 5, ugn = 528777 } + , UgnEdge { srcNode = 0x2581a285, srcPort = 6, dstNode = 0x2d01c345, dstPort = 6, ugn = 182272 } + -- → node 6 + , UgnEdge { srcNode = 0x2d01c345, srcPort = 0, dstNode = 0x04308185, dstPort = 0, ugn = -534879 } + , UgnEdge { srcNode = 0x2581a285, srcPort = 1, dstNode = 0x04308185, dstPort = 1, ugn = -352657 } + , UgnEdge { srcNode = 0x2c808445, srcPort = 2, dstNode = 0x04308185, dstPort = 2, ugn = -6154 } + , UgnEdge { srcNode = 0x05108285, srcPort = 3, dstNode = 0x04308185, dstPort = 3, ugn = -195568 } + , UgnEdge { srcNode = 0x044164c5, srcPort = 4, dstNode = 0x04308185, dstPort = 4, ugn = -397076 } + , UgnEdge { srcNode = 0x2c702305, srcPort = 5, dstNode = 0x04308185, dstPort = 5, ugn = -290908 } + , UgnEdge { srcNode = 0x2d20e405, srcPort = 6, dstNode = 0x04308185, dstPort = 6, ugn = -333671 } + -- → node 7 + , UgnEdge { srcNode = 0x2581a285, srcPort = 0, dstNode = 0x2d20e405, dstPort = 0, ugn = -18933 } + , UgnEdge { srcNode = 0x2d01c345, srcPort = 1, dstNode = 0x2d20e405, dstPort = 1, ugn = -201151 } + , UgnEdge { srcNode = 0x2c702305, srcPort = 2, dstNode = 0x2d20e405, dstPort = 2, ugn = 42816 } + , UgnEdge { srcNode = 0x2c808445, srcPort = 3, dstNode = 0x2d20e405, dstPort = 3, ugn = 327573 } + , UgnEdge { srcNode = 0x05108285, srcPort = 4, dstNode = 0x2d20e405, dstPort = 4, ugn = 138154 } + , UgnEdge { srcNode = 0x044164c5, srcPort = 5, dstNode = 0x2d20e405, dstPort = 5, ugn = -63350 } + , UgnEdge { srcNode = 0x04308185, srcPort = 6, dstNode = 0x2d20e405, dstPort = 6, ugn = 333780 } + ] +{- FOURMOLU_ENABLE -} + +{- | Weighted directed graph of 'goldenUgns', keyed on DNA-derived node ids. Useful for +looking up the latency from one node to another, checking reachability, and as input to +grooming or canonicalization algorithms. +-} +goldenGraph :: Graph (BitVector 32) Integer +goldenGraph = ugnGraph goldenUgns + +{- | The stored @λ^safe = λ^obs + ε@: 'goldenUgns' (the raw observed UGNs from the +reference boot) shifted up by a small safety margin 'marginFrames'. Every subsequent +boot is groomed onto this target (see 'computeRelabel'). +-} +lambdaSafe :: [UgnEdge] +lambdaSafe = safeMargin marginFrames goldenUgns + {- | Collect the configuration for the 'wireDemoPeConfig' and the 'programmableMux' in a single data structure for easier schedule generation. -} @@ -72,98 +201,47 @@ data NodeConfig linkCount = NodeConfig } deriving (Show) -{- | Generate a schedule for the wire demo, which consists of a configuration for the PE -and the programmable mux for each node. - -First generates an 'absolute' schedule with only a few rules: - - 'firstBCycle' is always 1 cycle after the previous node's 'firstBCycle' - - 'readLink' is always the previous node, except for the first node - - 'writeLink' is always the next node, except for the last node - -In this absolute schedule the links are FPGA indexed (not link indexed) and all -`firstBCycle`s are relative to the first node's `firstBCycle`. - -Next, this absolute schedule is converted to a 'relative' schedule by: - - Converting 'firstBCycle' to account for the counter mapping between the nodes - - Converting FPGA indexed links to link indices based on the FPGA setup -This process should be done in a chain-like manner. +{- | Chain topology for the wire demo: for each node, the link index over which it +reads the previous node and the link index over which it writes the next node +(@Nothing@ at the two ends of the chain). Purely structural -- it depends only on +the FPGA link map, not on any measured timing. The per-node @first_b_cycle@ is set +separately from the relabeled schedule (see 'driver'). -} -generateSchedule :: - forall nodeCount. - ( KnownNat nodeCount - , 1 <= nodeCount - ) => +chainTopology :: + (KnownNat nodeCount, 1 <= nodeCount) => Vec nodeCount (FpgaId, Vec (nodeCount - 1) (Index nodeCount)) -> - Vec nodeCount (Vec (nodeCount - 1) (Unsigned 64, Unsigned 64)) -> - Unsigned 64 -> - Vec nodeCount (NodeConfig (nodeCount - 1)) -generateSchedule fpgaTable ugnParts startCycle = genConfig <$> iterateI nextState (0, startCycle) + -- | Per node: @(readLink, writeLink)@ as link indices into that node's links. + Vec nodeCount (Maybe (Index (nodeCount - 1)), Maybe (Index (nodeCount - 1))) +chainTopology fpgaTable = imap node fpgaTable where - -- Map a clock cycle in the source domain to the destination domain. Goes through integer - -- because the mapping can be negative. If the result would be negative, return maxBound - -- to indicate that the event will not happen within the timespan of this test. - mapCycle :: Unsigned 64 -> Index nodeCount -> Index nodeCount -> Unsigned 64 - mapCycle srcCycle src dst = if dstCycleI < 0 then maxBound else fromInteger dstCycleI - where - dstCycleI = srcCycleI + counterMap !! src Map.! dst - srcCycleI = toInteger srcCycle - - ugnPartsI = map (map (bimap toInteger toInteger)) ugnParts - counterMap = Calc.toCounterMap internalDelay (Calc.toFpgaIndexed fpgaTable ugnPartsI) - - nextState (fpgaIndex, firstBCycle) = (fpgaIndex + 1, nextFirstBCycle) - where - nextFirstBCycle = mapCycle (firstBCycle + 1) fpgaIndex (fpgaIndex + 1) - - genConfig (fpgaIndex, firstBCycle) = - NodeConfig - { firstBCycle - , readLink = toLinkIndex fpgaIndex <$> readFpgaIdx - , writeLink = toLinkIndex fpgaIndex <$> writeFpgaIdx - } + node fpgaIndex (_, links) = (toLink <$> readFpgaIdx, toLink <$> writeFpgaIdx) where readFpgaIdx = if fpgaIndex == 0 then Nothing else Just (fpgaIndex - 1) writeFpgaIdx = if fpgaIndex == maxBound then Nothing else Just (fpgaIndex + 1) - - toLinkIndex currentFpga targetFpga = - let links = snd (fpgaTable !! currentFpga) - in fromJust $ elemIndex targetFpga links - -{- | Read the current time in clock cycles from the given target/device using the given -GDB connection. Requires the CPU to be halted. + toLink target = fromJust (elemIndex target links) + +{- | The fixed application-domain schedule, derived entirely from 'lambdaSafe' (and the +structural chain topology), hence constant across runs. After grooming, every node's +application counter is in the golden frame, so the PE chain fires at the golden boot's +schedule: per hop @k -> k+1@ the step is @1 + λ^safe_{k->k+1} + internalDelay@. Because +@λ^safe@ carries the golden boot's counter offsets, the running sum swings by those +offsets; it is shifted so the earliest @first_b_cycle@ is 'appScheduleBase' (all +non-negative). Boot offsets are removed by the per-node timed-reset release, not here. -} -readCurrentTime :: (HasCallStack) => MemoryMap -> (HwTarget, DeviceInfo) -> Gdb -> IO (Unsigned 64) -readCurrentTime mm (_, d) gdb = do - putStrLn $ "Getting current time from device " <> d.deviceId - commandAddress <- expectRight $ getPathAddress mm ["0", "Timer", "command"] - scratchAddress <- expectRight $ getPathAddress mm ["0", "Timer", "scratchpad"] - Gdb.writeLe gdb commandAddress Capture - Gdb.readLe gdb scratchAddress - -{- | Read the hardware UGNs of the given device using the given GDB connection. Requires -the CPU to be halted. --} -readHardwareUgns :: MemoryMap -> (HwTarget, DeviceInfo) -> Gdb -> IO [(Unsigned 64, Unsigned 64)] -readHardwareUgns mm (_, d) gdb = do - let - getUgnRegister reg = expectRight $ getPathAddress mm ["0", "CaptureUgns", reg] - - -- Adjust the local counter for the frames added/removed from the elastic - -- buffer after capturing the UGN. Leaves the remote counter untouched. - adjustLocalCounter :: (Unsigned 64, Unsigned 64, Signed 32) -> (Unsigned 64, Unsigned 64) - adjustLocalCounter (localCounter, remoteCounter, delta) = - (addSigned localCounter delta, remoteCounter) - where - addSigned :: Unsigned 64 -> Signed 32 -> Unsigned 64 - addSigned u s = checkedFromIntegral (toInteger u + toInteger s) - - liftIO $ putStrLn $ "Getting UGNs for device " <> d.deviceId - localCounters <- Gdb.readLe @(Vec LinkCount (Unsigned 64)) gdb =<< getUgnRegister "local_counter" - remoteCounters <- Gdb.readLe @(Vec LinkCount (Unsigned 64)) gdb =<< getUgnRegister "remote_counter" - deltas <- Gdb.readLe @(Vec LinkCount (Signed 32)) gdb =<< getUgnRegister "elastic_buffer_delta" - let ugnTriples = toList $ zip3 localCounters remoteCounters deltas - liftIO $ forM_ ugnTriples $ \triple -> putStrLn $ "Raw UGN triple: " <> show triple - pure $ adjustLocalCounter <$> ugnTriples +appSchedule :: Vec FpgaCount (NodeConfig (FpgaCount - 1)) +appSchedule = + zipWith + (\firstBCycle (readLink, writeLink) -> NodeConfig{firstBCycle, readLink, writeLink}) + appFbcs + (chainTopology fpgaSetup) + where + safeOf src dst = L.head [e.ugn | e <- lambdaSafe, e.srcNode == src, e.dstNode == dst] + step k = 1 + toInteger (safeOf (indexToNodeId k) (indexToNodeId (k + 1))) + toInteger internalDelay + steps = map step (init indicesI) :: Vec (FpgaCount - 1) Integer + cumulative = scanl (+) 0 steps :: Vec FpgaCount Integer + minCumulative = minimum cumulative + appFbcs = + map (\c -> checkedFromIntegral (toInteger appScheduleBase + c - minCumulative)) cumulative writePeConfig :: ( HasCallStack @@ -337,20 +415,110 @@ driver testName targets = do putStrLn "UGN pairs table:" mapM_ print ugnPairsTableV - -- Calculate schedule and write configurations + -- Relabel this boot onto the stored target latencies, using the grooming + -- utilities ("Bittide.Instances.Hitl.Utils.UgnGrooming"): build UGN + -- edges from the measured counters, groom them (Bellman-Ford) onto + -- λ^safe = the target latency on both directions of every chain hop, and turn + -- the result into per-node reset releases (which remove the boot-time counter + -- offsets) and per-link frame corrections (which raise each link's latency to + -- the target). The application then runs one FIXED schedule built from the + -- target latencies -- identical every run. currentTime <- liftIO $ readCurrentTime MemoryMaps.managementUnit (L.head targets) (L.head managementUnitGdbs) let - startOffset = currentTime + natToNum @(PeriodToCycles GthTx (Seconds StartDelay)) - schedule = generateSchedule fpgaSetup ugnPairsTableV startOffset + -- Shared reference instant for the relabel: node 0's current time plus a + -- large constant. 'StartDelay' must exceed the GDB bookkeeping time, else a + -- node's reset could release before it is fully configured. + sharedBase = currentTime + natToNum @(PeriodToCycles GthTx (Seconds StartDelay)) + + -- This boot's measured UGN edges for the FULL graph (every node, every + -- link) -- the grooming is independent of the application and adjusts all + -- elastic buffers. + measuredEdges = hardwareUgnEdges ugnPairsTableV + + -- Groom this boot's measured UGNs onto the stored 'lambdaSafe' (Bellman-Ford): + -- per-node reset offsets + per-link frame corrections for every edge. + -- 'appSchedule' is fixed and derived from the same 'lambdaSafe'. + relabelPlan <- case computeRelabel measuredEdges lambdaSafe of + Left ns -> + fail + $ "UGN grooming infeasible (UGNs changed too much); negative cycle through nodes: " + <> show ns + Right plan -> pure plan + + let + -- tReset_i = sharedBase + reset offset (the relabel that removes node i's + -- boot-time counter offset; the application counter is 0 at release). + tResets :: Vec FpgaCount (Unsigned 64) + tResets = + map (\off -> checkedFromIntegral (toInteger sharedBase + toInteger off)) relabelPlan.resetOffsets + liftIO $ do + putStrLn + [i|Shared reference base (node 0 now + #{natToNum @StartDelay :: Integer}s): #{sharedBase}|] + putStrLn + [i|Grooming onto stored lambda^safe (#{L.length lambdaSafe} edges, margin #{marginFrames})|] + putStrLn "Per-node reset offset (relabel q, gauged to node 0):" + mapM_ print (toList relabelPlan.resetOffsets) + putStrLn "Per-node tReset (= sharedBase + reset offset):" + mapM_ print (toList tResets) + putStrLn "Per-node frame corrections (per link):" + mapM_ print (toList relabelPlan.corrections) + putStrLn "Fixed app-domain schedule (relabeled domain, same every run):" + mapM_ print appSchedule + + -- Step 3: write the fixed application configuration (in the relabeled domain). + _ <- sequenceA $ L.zipWith3 writePeConfig targets managementUnitGdbs (toList appSchedule) + _ <- + sequenceA $ L.zipWith3 writeProgrammableMuxConfig targets managementUnitGdbs (toList appSchedule) + + -- Step 5: apply the elastic-buffer corrections BEFORE the application runs. + -- The application is still held in reset (tReset not yet set), so resuming + -- the MUs only runs the corrections firmware, which applies the frames + -- (within a safe occupancy margin) and reports success; then halt again. liftIO $ do - putStrLn "Generated schedule:" - mapM_ print schedule - liftIO $ putStrLn [i|Starting clock cycle: #{startOffset}|] - _ <- sequenceA $ L.zipWith3 writePeConfig targets managementUnitGdbs (toList schedule) - _ <- sequenceA $ L.zipWith3 writeProgrammableMuxConfig targets managementUnitGdbs (toList schedule) + putStrLn "\n=== Applying elastic-buffer corrections ===" + mapConcurrently_ + (\(gdb, corr) -> writeCorrections MemoryMaps.managementUnit gdb corr) + (L.zip managementUnitGdbs (toList relabelPlan.corrections)) + mapConcurrently_ Gdb.continue managementUnitGdbs + T.tryWithTimeoutOn + T.PrintActionTime + "Waiting for corrections to be applied" + 60_000_000 + goDumpCcSamples + $ forConcurrently_ serials + $ \serial -> waitForLine serial "[MU] Corrections applied successfully" + mapConcurrently_ Gdb.interrupt managementUnitGdbs - -- Wait for test completion + -- Step 6: set tReset so every node's application leaves reset at the relabeled + -- moment (its application counter becomes 0 there). + liftIO $ do + putStrLn "\n=== Setting tReset ===" + forM_ (L.zip managementUnitGdbs (toList tResets)) $ \(gdb, tReset) -> + writeReleaseCycle MemoryMaps.managementUnit gdb tReset + + -- Safety check: every node must still be *before* its tReset. Writing the + -- configs + corrections + tReset over GDB takes time; if it took longer than + -- the 'StartDelay' margin, a node's local counter may already have passed its + -- tReset, in which case its reset releases (and the programmable mux's + -- equality trigger is missed) before the schedule is in effect. + releaseInTime <- + liftIO $ forM (L.zip3 targets managementUnitGdbs (toList tResets)) $ \(tgt@(_, d), gdb, tReset) -> do + let did = d.deviceId + now <- readCurrentTime MemoryMaps.managementUnit tgt gdb + let inTime = now < tReset + if inTime + then + putStrLn + [i| #{did}: ok (now=#{now} < tReset=#{tReset}, margin=#{toInteger tReset - toInteger now} cycles)|] + else + putStrLn + [i| [ERROR] #{did}: now=#{now} has already passed tReset=#{tReset} — setup took too long|] + pure inTime + unless (L.and releaseInTime) + $ fail "Reset-release window missed: setup took longer than the StartDelay margin" + + -- Step 7: wait for the application to run, then read back and verify the data. liftIO $ do let delayMicros = natToNum @((StartDelay + 1) * 1_000_000) putStrLn [i|Sleeping for: #{delayMicros}μs ...|] @@ -359,7 +527,21 @@ driver testName targets = do readCurrentTime MemoryMaps.managementUnit (L.head targets) (L.head managementUnitGdbs) putStrLn [i|Clock is now: #{newCurrentTime}|] - -- Verify test results + -- Relabel-landing check: with the app running, read each node's app_counter + -- and localCounter. If the timed-reset relabel landed, localCounter - + -- app_counter == tReset for that node. A divergence pinpoints a node whose + -- reset did not release at the intended (relabeled) cycle. + putStrLn "\n=== Relabel landing (localCounter - app_counter should == tReset) ===" + forM_ (L.zip3 targets managementUnitGdbs (toList tResets)) $ \(tgt@(_, dev), gdb, tReset) -> do + appCtr <- readAppCounter MemoryMaps.managementUnit tgt gdb + locCtr <- readCurrentTime MemoryMaps.managementUnit tgt gdb + let + observed = toInteger locCtr - toInteger appCtr + did = dev.deviceId + diff = observed - toInteger tReset + putStrLn + [i| #{did}: app_counter=#{appCtr} loc-app=#{observed} tReset=#{tReset} diff=#{diff}|] + let dnas = L.map (.dna) demoRigInfo expectedWrittenDatas = L.tail $ L.scanl xor 0 $ L.map resize dnas diff --git a/bittide-instances/src/Bittide/Instances/Hitl/WireDemo/UserCore.hs b/bittide-instances/src/Bittide/Instances/Hitl/WireDemo/UserCore.hs index 7d917d72b0..a7a25fbfdf 100644 --- a/bittide-instances/src/Bittide/Instances/Hitl/WireDemo/UserCore.hs +++ b/bittide-instances/src/Bittide/Instances/Hitl/WireDemo/UserCore.hs @@ -15,25 +15,67 @@ module Bittide.Instances.Hitl.WireDemo.UserCore ( ) where import Clash.Explicit.Prelude -import Clash.Prelude (withClock, withClockResetEnable) +import Clash.Prelude (HiddenClockResetEnable, withClock, withClockResetEnable) import Protocols import Bittide.Instances.Hitl.GenericDemo.BringUp (NmuRemBusWidth, UserCoreCircuit) import Bittide.Instances.Hitl.Setup (LinkCount) import Bittide.ProgrammableMux (programmableMux) +import Bittide.SharedTypes (BitboneMm) import Bittide.WireDemoProcessingElement (wireDemoPe, wireDemoPeConfig) -type UserCoreBusses = 2 +import Clash.Class.BitPackC (ByteOrder) +import GHC.Stack (HasCallStack) +import Protocols.MemoryMap (Access (ReadOnly)) +import Protocols.MemoryMap.Registers.WishboneStandard ( + RegisterConfig (access), + deviceConfig, + deviceWbI, + registerConfig, + registerWbI_, + ) + +type UserCoreBusses = 3 type RingBufferDepth = 200 ringBufferDepth :: SNat RingBufferDepth ringBufferDepth = SNat +{- | A read-only scratch peripheral exposing the application counter so the host can +verify, after the schedule has run, that the timed-reset relabel landed: at any moment +@localCounter - appCounter == tReset@ for that node, so the per-node release timing (the +UGN-grooming relabel @q@) can be checked directly over GDB. +-} +appCounterReadbackWb :: + forall dom addrW. + ( HasCallStack + , HiddenClockResetEnable dom + , KnownNat addrW + , ?byteOrder :: ByteOrder + ) => + Signal dom (Unsigned 64) -> + Circuit (BitboneMm dom addrW) () +appCounterReadbackWb appCounter = circuit $ \bus -> do + [wbAppCounter] <- deviceWbI (deviceConfig "AppCounterReadback") -< bus + registerWbI_ (registerConfig "app_counter" ""){access = ReadOnly} (0 :: Unsigned 64) + -< (wbAppCounter, Fwd (Just <$> appCounter)) + mkUserCore :: UserCoreCircuit UserCoreBusses (NmuRemBusWidth UserCoreBusses) -mkUserCore bitClk bitRst bitEna localCounter maybeDna = +mkUserCore bitClk bitRst bitEna localCounter maybeDna appReset = circuit $ \(userCoreBusses, Fwd rxs2Raw, handshakeOut) -> do - [muProgrammableMuxBus, peConfigBus] <- idC -< userCoreBusses + [muProgrammableMuxBus, peConfigBus, appCounterBus] <- idC -< userCoreBusses + + -- The application counter is the relabeled time base: it starts counting when + -- the management unit releases 'appReset' (see 'timedResetWb' in the MU), so the + -- per-node release timing realizes the UGN-grooming relabel. The reset itself + -- lives outside the user core (in the layer with the management unit). + let appCounter = register bitClk appReset bitEna 0 (appCounter + 1) + + -- Expose the application counter read-only so the host can confirm the relabel + -- actually landed on hardware (localCounter - appCounter == tReset per node). + withClockResetEnable bitClk bitRst bitEna (appCounterReadbackWb appCounter) + -< appCounterBus -- Start business logic (readLinkI, writeLinkI) <- @@ -46,9 +88,12 @@ mkUserCore bitClk bitRst bitEna localCounter maybeDna = -- Stop business logic -- Start programmable mux + -- The programmable mux is driven by the relabeled application counter (started + -- by the timed reset), so a static schedule (fixed `first_b_cycle`) fires at the + -- correct physical moment once the node has been relabeled to the reference. (Fwd businessLogicReset, txsOut) <- withClockResetEnable bitClk bitRst bitEna - $ programmableMux localCounter + $ programmableMux appCounter -< (muProgrammableMuxBus, handshakeOut, Fwd (bundle txsBl)) -- Stop programmable mux diff --git a/bittide-instances/src/Bittide/Instances/Tests/ElasticBufferWb.hs b/bittide-instances/src/Bittide/Instances/Tests/ElasticBufferWb.hs index 70470c56db..09592b4237 100644 --- a/bittide-instances/src/Bittide/Instances/Tests/ElasticBufferWb.hs +++ b/bittide-instances/src/Bittide/Instances/Tests/ElasticBufferWb.hs @@ -82,7 +82,9 @@ dut dumpVcd peConfig = xilinxElasticBufferWb clockGen (resetGenN d2) - d5 + -- Keep this in sync with 'Bittide.Instances.Hitl.GenericDemo.Core.FifoSize' + -- so the generated @ElasticBuffer@ HAL device stays a single shared device. + d6 localCounter clockGen (pure () :: Signal XilinxSystem ()) diff --git a/bittide-instances/tests/Tests/Bittide/Instances/Hitl/Utils/UgnGrooming.hs b/bittide-instances/tests/Tests/Bittide/Instances/Hitl/Utils/UgnGrooming.hs new file mode 100644 index 0000000000..e29e791c62 --- /dev/null +++ b/bittide-instances/tests/Tests/Bittide/Instances/Hitl/Utils/UgnGrooming.hs @@ -0,0 +1,195 @@ +-- SPDX-FileCopyrightText: 2026 Google LLC +-- +-- SPDX-License-Identifier: Apache-2.0 + +module Tests.Bittide.Instances.Hitl.Utils.UgnGrooming where + +import Prelude + +import Clash.Prelude (BitVector, Index, Signed) + +import Bittide.ClockControl.Ugn.Grooming (GroomResult (..), groomCorrection) +import Bittide.Graph.Weighted (edges, weight) +import Bittide.Instances.Hitl.Setup (LinkCount) +import Bittide.Instances.Hitl.Utils.Ugn (UgnEdge (..)) +import Bittide.Instances.Hitl.Utils.UgnGrooming + +import Control.Monad (forM_) +import Data.List (sort) +import Test.Tasty +import Test.Tasty.HUnit +import Test.Tasty.Hedgehog (testProperty) +import Test.Tasty.TH (testGroupGenerator) + +import Hedgehog ((===)) + +import Data.Map.Strict (Map) + +import qualified Data.Map.Strict as Map +import qualified Hedgehog as H +import qualified Hedgehog.Gen as Gen +import qualified Hedgehog.Range as Range + +-- * Helpers + +mkEdge :: BitVector 32 -> Index LinkCount -> BitVector 32 -> Index LinkCount -> Signed 64 -> UgnEdge +mkEdge = UgnEdge + +-- * Adapter unit tests + +case_safeMargin_adds_eps :: Assertion +case_safeMargin_adds_eps = do + let es = [mkEdge 0 0 1 0 10, mkEdge 1 0 0 0 (-4)] + map (.ugn) (safeMargin 5 es) @?= [15, 1] + +case_ugnGraph_roundtrip :: Assertion +case_ugnGraph_roundtrip = do + let + es = [mkEdge 0 0 1 0 7, mkEdge 1 0 0 0 (-3)] + g = ugnGraph es + sort (edges g) @?= sort [(0, 1, 7), (1, 0, -3)] + weight 0 1 g @?= Just 7 + weight 1 0 g @?= Just (-3) + +-- * Property: grooming restores a freshly-booted mesh to a prior state + +-- | The four nodes of the modelled mesh, identified by their (DNA-derived) node ids. +nodeIds :: [BitVector 32] +nodeIds = [0, 1, 2, 3] + +-- | All directed links of the full mesh (one per ordered node pair). +nodePairs :: [(BitVector 32, BitVector 32)] +nodePairs = [(a, b) | a <- nodeIds, b <- nodeIds, a /= b] + +{- | Reference fiber/transceiver latency per directed link, in cycles. Fixed hardware +characteristic of the topology — hard-coded here just as the implementation stores a +fixed reference. Asymmetric on purpose (forward/return links differ) to exercise the +directed-graph handling. +-} +referenceFibers :: Map (BitVector 32, BitVector 32) Integer +referenceFibers = + Map.fromList $ + zip + nodePairs + -- (0,1)(0,2)(0,3) (1,0)(1,2)(1,3) (2,0)(2,1)(2,3) (3,0)(3,1)(3,2) + [120, 340, 275, 118, 260, 410, 345, 262, 190, 280, 405, 188] + +{- | Per-node sender-side pipeline delay, in cycles. A fixed hardware characteristic, +shared by every boot. +-} +sendDelays :: Map (BitVector 32) Integer +sendDelays = Map.fromList (zip nodeIds [7, 11, 5, 9]) + +{- | The reference boot's per-node boot delay (the cycle each node leaves reset). +Hard-coded: together with 'referenceFibers' it pins down the stored golden UGN set, +exactly as the implementation keeps a fixed reference rather than re-deriving it. +-} +goldenBootDelays :: Map (BitVector 32) Integer +goldenBootDelays = Map.fromList (zip nodeIds [1000, 4200, 350, 2750]) + +{- | Reference (golden) UGN set: the stored snapshot we groom back to. Derived once from +the hard-coded reference latencies and golden boot delays, with no elastic-buffer +contribution (the elastic buffers sit at their reference position). +-} +goldenUgns :: [UgnEdge] +goldenUgns = meshUgns goldenBootDelays (const 0) + +{- | Build the mesh's UGN edges for one boot, exactly as the demo derives them: form the +two captured counters, then take their signed difference (see +'Bittide.Instances.Hitl.Utils.Ugn.timingOracleToUgnEdge', @ugn = localCounter - +remoteCounter@). A node's counter is just wall-clock time shifted by when it left reset: +node @i@'s counter reads @absoluteTime - bootDelay_i@. For a directed link @a -> b@, node +@a@ stamps a frame with its counter at some send time @t@; that frame reaches @b@ +@fiber + eb@ cycles later, where @b@ captures its own counter: + + * @remoteCounter = (t - bootDelay_a) + sendDelay_a@ — sender's counter, captured at @b@. + * @localCounter = ((t + fiber_{a->b} + eb_{a->b}) - bootDelay_b) + sendDelay_a@ — + receiver's counter at arrival. + + * @UGN_{a->b} = localCounter - remoteCounter + = fiber_{a->b} + eb_{a->b} + bootDelay_a - bootDelay_b@. + +The send time @t@ and the sender pipeline @sendDelay_a@ cancel in the difference (the +elastic buffer / fiber sit on the path between the two captures). @bootDelay@ is the +(per-boot, nondeterministic) reset-release cycle — a fact of /this/ boot, not a relabeling +we choose. Because @bootDelay_a@ enters with @+@ and @bootDelay_b@ with @-@, every +round-trip @UGN_{a->b} + UGN_{b->a}@ is boot-delay-independent (@= 2·fiber + 2·eb@), +honouring the physical round-trip constraint. +-} +meshUgns :: + -- | Per-node boot delay + Map (BitVector 32) Integer -> + -- | Per-link elastic-buffer latency + ((BitVector 32, BitVector 32) -> Integer) -> + [UgnEdge] +meshUgns bootDelays ebOf = + [ mkEdge a 0 b 0 (fromIntegral (localCounter - remoteCounter)) + | (a, b) <- nodePairs + , let bootDelayOf n = Map.findWithDefault 0 n bootDelays + -- An arbitrary fixed send time; it cancels in the difference. + sendTime = 0 + remoteCounter = (sendTime - bootDelayOf a) + sendDelays Map.! a + localCounter = + ((sendTime + referenceFibers Map.! (a, b) + ebOf (a, b)) - bootDelayOf b) + + sendDelays Map.! a + ] + +{- | Grooming a freshly-booted mesh back onto a fixed stored reference reconstructs the +reference UGNs exactly. + +The reference topology ('referenceFibers'), sender pipeline ('sendDelays'), golden boot +('goldenBootDelays') and the resulting golden UGN set ('goldenUgns') are hard-coded — the +implementation likewise grooms onto a stored reference, not a freshly re-measured one. We +generate the /fresh/ boot: a new per-node boot delay and a small per-link elastic-buffer +deviation from the reference position. From those we synthesize the measured UGNs with the +same counter model, then groom them onto @λ^safe = goldenUgns + ε@ with 'groomCorrection'. + +Grooming returns a per-node relabel @q@ (distinct from the hardware's @bootDelay@) plus +small per-link frame insertions. With a strictly positive margin @ε@ the restore is always +feasible: the slack on link @a -> b@ is @(D_a - D_b) + ε - ebDev_{a->b}@ where +@D_i = goldenBootDelay_i - currentBootDelay_i@ and @ebDev@ is the (bounded) elastic-buffer +deviation. Over any directed cycle the @D@ terms telescope away, leaving @Σ(ε - ebDev) > 0@ +(we keep @ebDev < ε@), so there is no negative cycle. We assert the restore is feasible, +every frame insertion is nonnegative, and the relabel-then-pad reconstructs @λ^safe@ on +every link: @λ_{a->b} + q_b - q_a + frames_{a->b} == λ^safe_{a->b}@. +-} +prop_relabelRestoresPriorBoot :: H.Property +prop_relabelRestoresPriorBoot = H.property $ do + let margin = 5 :: Signed 64 + + -- The fresh boot: a new per-node boot delay (the nondeterministic reset-release cycle). + currentBootDelaysList <- + H.forAll $ Gen.list (Range.singleton 4) (Gen.integral (Range.linear 0 100_000)) + -- Per-link elastic-buffer deviation from the reference position: small, and strictly + -- below the margin so the residual stays a nonnegative insertion. + ebDevs <- + H.forAll $ + Gen.list (Range.singleton (length nodePairs)) (Gen.integral (Range.linear 0 4)) + + let + currentBootDelays = Map.fromList (zip nodeIds currentBootDelaysList) + ebDevOf = Map.findWithDefault 0 `flip` Map.fromList (zip nodePairs ebDevs) + + currentUgns = meshUgns currentBootDelays ebDevOf + lambdaSafe = safeMargin margin goldenUgns + currentByKey = Map.fromList [((e.srcNode, e.dstNode), toInteger e.ugn) | e <- currentUgns] + lambdaSafeByKey = Map.fromList [((e.srcNode, e.dstNode), toInteger e.ugn) | e <- lambdaSafe] + + case groomCorrection (ugnGraph currentUgns) (ugnGraph lambdaSafe) of + Infeasible ns -> do + H.annotateShow ns + H.failure + Feasible{correction = q, framesToInsert} -> do + -- 'q' is the per-node relabel grooming computed; every frame correction is a + -- nonnegative insertion. + H.assert (all (\(_, _, f) -> f >= 0) framesToInsert) + -- Relabel-then-pad reconstructs the stored reference UGNs on every link: + -- λ_{a->b} + q_b - q_a + frames_{a->b} == λ^safe_{a->b} + forM_ framesToInsert $ \(i, j, f) -> do + let + qOf n = Map.findWithDefault 0 n q + lookupKey k m = Map.findWithDefault (error "edge missing") k m + lookupKey (i, j) currentByKey + qOf j - qOf i + f === lookupKey (i, j) lambdaSafeByKey + +tests :: TestTree +tests = $(testGroupGenerator) diff --git a/bittide-instances/tests/unittests.hs b/bittide-instances/tests/unittests.hs index fd3e745c33..31244b6113 100644 --- a/bittide-instances/tests/unittests.hs +++ b/bittide-instances/tests/unittests.hs @@ -19,6 +19,7 @@ import qualified Df.WbToDf as WbToDf import qualified Tests.Bittide.Instances.Hitl.Utils.MemoryMap as MemoryMap import qualified Tests.Bittide.Instances.Hitl.Utils.OpenOcd as OpenOcd import qualified Tests.Bittide.Instances.Hitl.Utils.Serial as Serial +import qualified Tests.Bittide.Instances.Hitl.Utils.UgnGrooming as UgnGrooming import qualified Tests.ClockControlWb as ClockControlWb import qualified Wishbone.AddressableBytesWb as AddressableBytesWb import qualified Wishbone.Axi as Axi @@ -85,6 +86,7 @@ prepareTests = do , RingBuffer.tests , Serial.tests , Time.tests + , UgnGrooming.tests , Watchdog.tests , WbToDf.tests ] diff --git a/bittide/bittide.cabal b/bittide/bittide.cabal index ef40c52b4b..cfaabf1388 100644 --- a/bittide/bittide.cabal +++ b/bittide/bittide.cabal @@ -206,12 +206,15 @@ library Bittide.ClockControl.Si539xSpi Bittide.ClockControl.Topology Bittide.ClockControl.Topology.LinkMasks + Bittide.ClockControl.Ugn.Corrections + Bittide.ClockControl.Ugn.Grooming Bittide.Counter Bittide.Df Bittide.DoubleBufferedRam Bittide.ElasticBuffer Bittide.ElasticBuffer.AutoCenter Bittide.Ethernet.Mac + Bittide.Graph.Weighted Bittide.Handshake Bittide.Jtag Bittide.PacketStream @@ -224,6 +227,7 @@ library Bittide.SharedTypes Bittide.Shutter Bittide.Sync + Bittide.TimedReset Bittide.Transceiver Bittide.Transceiver.Cdc Bittide.Transceiver.Comma @@ -293,15 +297,18 @@ test-suite unittests Tests.Clash.Protocols.Wishbone.Extra Tests.ClockControl.Freeze Tests.ClockControl.Si539xSpi + Tests.ClockControl.Ugn.Grooming Tests.Counter Tests.DelayWishbone Tests.Df Tests.DoubleBufferedRam Tests.ElasticBuffer + Tests.Graph.Weighted Tests.Handshake Tests.ProcessingElement.ReadElf Tests.ProgrammableMux Tests.Shared + Tests.TimedReset Tests.Transceiver Tests.Transceiver.Prbs Tests.Transceiver.WordAlign diff --git a/bittide/src/Bittide/ClockControl/Ugn/Corrections.hs b/bittide/src/Bittide/ClockControl/Ugn/Corrections.hs new file mode 100644 index 0000000000..fac15d84e5 --- /dev/null +++ b/bittide/src/Bittide/ClockControl/Ugn/Corrections.hs @@ -0,0 +1,67 @@ +-- SPDX-FileCopyrightText: 2026 Google LLC +-- +-- SPDX-License-Identifier: Apache-2.0 + +{- | +A small Wishbone scratch peripheral holding the UGN grooming corrections that the +host computes and the management-unit CPU applies. + +It exposes two read/write registers: + + * @corrections@ — a vector of @n@ signed correction values (initialised to all + zeros). + * @valid@ — a flag indicating the corrections are ready to be applied + (initialised to 'False'). + +The intended flow (see "Bittide.ClockControl.Ugn.Grooming"): + + 1. The CPU finishes its normal work and spins, polling @valid@ over MMIO. + 2. The host computes the corrections, halts the CPU via GDB, writes + @corrections@, sets @valid@ to 'True', and resumes the CPU. + 3. On its next poll the CPU sees @valid == True@, reads @corrections@, and + applies them. + +Both registers are plain bus-accessible storage (no fabric write port): the CPU +and the host both reach them as Wishbone masters. Nothing reads them from the +fabric, so the peripheral has no outputs. +-} +module Bittide.ClockControl.Ugn.Corrections (correctionsWb) where + +import Clash.Explicit.Prelude + +import Bittide.SharedTypes (BitboneMm) +import GHC.Stack (HasCallStack) +import Protocols +import Protocols.MemoryMap.Registers.WishboneStandard ( + deviceConfig, + deviceWbI, + registerConfig, + registerWbI_, + ) + +import Clash.Class.BitPackC (ByteOrder) + +import qualified Clash.Prelude as C + +{- | UGN grooming corrections peripheral. Holds @n@ signed corrections and a +validity flag, all writable and readable over Wishbone. See the module header +for the intended host/CPU handshake. +-} +correctionsWb :: + forall n dom addrW. + ( HasCallStack + , C.HiddenClockResetEnable dom + , KnownNat addrW + , ?byteOrder :: ByteOrder + ) => + SNat n -> + Circuit (BitboneMm dom addrW) () +correctionsWb SNat = circuit $ \bus -> do + [wbCorrections, wbValid] <- deviceWbI (deviceConfig "UgnCorrections") -< bus + registerWbI_ correctionsConfig initCorrections -< (wbCorrections, Fwd noWrite) + registerWbI_ validConfig False -< (wbValid, Fwd noWrite) + where + noWrite = pure Nothing + initCorrections = C.repeat 0 :: Vec n (Signed 64) + correctionsConfig = registerConfig "corrections" "" + validConfig = registerConfig "valid" "" diff --git a/bittide/src/Bittide/ClockControl/Ugn/Grooming.hs b/bittide/src/Bittide/ClockControl/Ugn/Grooming.hs new file mode 100644 index 0000000000..13d92e6b5a --- /dev/null +++ b/bittide/src/Bittide/ClockControl/Ugn/Grooming.hs @@ -0,0 +1,85 @@ +-- SPDX-FileCopyrightText: 2026 Google LLC +-- +-- SPDX-License-Identifier: Apache-2.0 + +{- | +UGN grooming, expressed on top of "Bittide.Graph.Weighted". + +UGNs (@\lambda_{i->j}@) are signed per-link clock/frame offsets, i.e. edge weights +on a directed graph of nodes. They cannot be chosen independently: the sum around +every directed cycle must be nonnegative (the physical round-trip constraint). + +This module provides 'groomCorrection': given a freshly measured @\lambda@ and a +previously stored @\lambda^{safe} = \lambda^{obs} + \varepsilon@, it finds a relabeling +@q@ and the frames to insert so the system is restored to @\lambda^{safe}@, or reports +that the UGNs changed too much. (Whether a set of UGNs is physically realisable at all — +no negative cycle — is 'Bittide.Graph.Weighted.negativeCycle'.) + +The math (see the UGN grooming slides): with slack +@c_{i->j} = \lambda^{safe}_{i->j} - \lambda_{i->j}@, the feasibility condition +@q_j - q_i <= c_{i->j}@ is a difference-constraint system, solved by Bellman-Ford +from a virtual super-source. It is feasible iff the slack graph has no negative +cycle, and the resulting potentials @q@ satisfy the per-edge constraint, so the +padding @\lambda^{safe} - (\lambda + q_j - q_i)@ is guaranteed nonnegative. +-} +module Bittide.ClockControl.Ugn.Grooming ( + GroomResult (..), + groomCorrection, +) where + +import Prelude + +import Data.Map.Strict (Map) + +import Bittide.Graph.Weighted + +import qualified Data.Map.Strict as Map + +-- | Outcome of 'groomCorrection'. +data GroomResult n w + = {- | The slack graph has a negative cycle (UGNs changed too much); the + witnessed cycle is included for diagnostics. + -} + Infeasible [n] + | {- | A feasible restore: relabel by 'correction', then insert + 'framesToInsert' frames per edge to reach @\lambda^{safe}@. + -} + Feasible + { correction :: Map n w + , framesToInsert :: [(n, n, w)] + } + deriving (Show, Eq) + +{- | Find the correction restoring measured UGNs @\lambda@ to the stored safe UGNs +@\lambda^{safe}@. + +The slack graph has edge weights @\lambda^{safe}_{i->j} - \lambda_{i->j}@; its +Bellman-Ford potentials give the relabeling @q@. The frames to insert on each edge +are @\lambda^{safe}_{i->j} - (\lambda_{i->j} + q_j - q_i)@, which are @>= 0@ by the +shortest-path relaxation invariant. + +Edges are matched between the two graphs by 'zipEdgesWith'; only edges present in +both are considered. +-} +groomCorrection :: + (Ord n, Ord w, Num w) => + -- | Measured @\lambda@ + Graph n w -> + -- | Stored @\lambda^{safe}@ + Graph n w -> + GroomResult n w +groomCorrection measured safe = + case bellmanFord slack of + NegativeCycle c -> Infeasible c + Distances q -> + Feasible + { correction = q + , framesToInsert = + [ (i, j, safeW - (lam + qOf q j - qOf q i)) + | (i, j, lam) <- edges measured + , Just safeW <- [weight i j safe] + ] + } + where + slack = zipEdgesWith (\lam safeW -> safeW - lam) measured safe + qOf q n = Map.findWithDefault 0 n q diff --git a/bittide/src/Bittide/Graph/Weighted.hs b/bittide/src/Bittide/Graph/Weighted.hs new file mode 100644 index 0000000000..b371d71882 --- /dev/null +++ b/bittide/src/Bittide/Graph/Weighted.hs @@ -0,0 +1,235 @@ +-- SPDX-FileCopyrightText: 2026 Google LLC +-- +-- SPDX-License-Identifier: Apache-2.0 + +{- | +A directed, weighted graph backed by a 'Data.Map' adjacency structure, together +with the shortest-path / negative-cycle machinery needed for UGN grooming. + +The node label @n@ is generic (only 'Ord' is required), so callers can use a +domain type directly (e.g. a DNA-derived @BitVector 32@) without an intermediate +index-mapping layer. The weight @w@ is likewise generic; grooming uses 'Integer' +to avoid overflow during relaxation. + +Note: at most one edge is stored per ordered pair @(i, j)@ ('addEdge' overwrites +a previous weight). This matches the complete-graph bittide demo topology, which +has exactly one directed link per ordered node pair. A topology with parallel +links between the same node pair would need a multigraph representation here. +-} +module Bittide.Graph.Weighted ( + -- * Type + Graph, + + -- * Construction & query + empty, + fromEdges, + addEdge, + nodes, + edges, + nodeCount, + edgeCount, + hasEdge, + weight, + outgoing, + incoming, + + -- * Transforms + mapWeights, + transpose, + relabel, + zipEdgesWith, + + -- * Paths & cycles + ShortestPaths (..), + bellmanFord, + potentials, + negativeCycle, + cycleSum, +) where + +import Prelude + +import Data.Map.Strict (Map) +import Data.Maybe (fromMaybe) + +import qualified Data.List as L +import qualified Data.Map.Strict as Map + +{- | A directed, weighted graph. Invariant: every node that appears as the source +or destination of an edge is a key of the outer map (with an empty inner map if +it has no outgoing edges), so 'nodes' reports isolated and sink nodes too. +-} +newtype Graph n w = Graph (Map n (Map n w)) + deriving (Show, Eq) + +-- | The empty graph (no nodes, no edges). +empty :: Graph n w +empty = Graph Map.empty + +{- | Build a graph from an edge list @(src, dst, weight)@. On a duplicate ordered +pair the later entry in the list wins. +-} +fromEdges :: (Ord n) => [(n, n, w)] -> Graph n w +fromEdges = L.foldl' (\g (i, j, w) -> addEdge i j w g) empty + +{- | Insert (or overwrite) the edge @i -> j@ with the given weight, registering +both endpoints as nodes. +-} +addEdge :: (Ord n) => n -> n -> w -> Graph n w -> Graph n w +addEdge i j w (Graph m) = Graph m2 + where + -- Ensure 'j' exists as a node without clobbering its outgoing edges. + m1 = Map.insertWith (\_new old -> old) j Map.empty m + m2 = Map.alter (Just . Map.insert j w . fromMaybe Map.empty) i m1 + +-- | All nodes, in ascending order. +nodes :: Graph n w -> [n] +nodes (Graph m) = Map.keys m + +-- | All edges as @(src, dst, weight)@. +edges :: Graph n w -> [(n, n, w)] +edges (Graph m) = + [(i, j, w) | (i, dsts) <- Map.toList m, (j, w) <- Map.toList dsts] + +-- | Number of nodes. +nodeCount :: Graph n w -> Int +nodeCount (Graph m) = Map.size m + +-- | Number of edges. +edgeCount :: Graph n w -> Int +edgeCount (Graph m) = sum (Map.size <$> Map.elems m) + +-- | Is there an edge @i -> j@? +hasEdge :: (Ord n) => n -> n -> Graph n w -> Bool +hasEdge i j (Graph m) = maybe False (Map.member j) (Map.lookup i m) + +-- | Weight of edge @i -> j@, if present. +weight :: (Ord n) => n -> n -> Graph n w -> Maybe w +weight i j (Graph m) = Map.lookup i m >>= Map.lookup j + +-- | Outgoing edges of @i@ as @(dst, weight)@. +outgoing :: (Ord n) => n -> Graph n w -> [(n, w)] +outgoing i (Graph m) = maybe [] Map.toList (Map.lookup i m) + +-- | Incoming edges of @j@ as @(src, weight)@. +incoming :: (Ord n) => n -> Graph n w -> [(n, w)] +incoming j (Graph m) = + [(i, w) | (i, dsts) <- Map.toList m, Just w <- [Map.lookup j dsts]] + +-- | Apply a function to every edge weight. +mapWeights :: (w -> w') -> Graph n w -> Graph n w' +mapWeights f (Graph m) = Graph (Map.map (Map.map f) m) + +-- | Reverse every edge, preserving the node set. +transpose :: (Ord n) => Graph n w -> Graph n w +transpose g@(Graph m) = + L.foldl' (\acc (i, j, w) -> addEdge j i w acc) base (edges g) + where + -- Keep all nodes (so isolated/sink nodes survive), drop all edges. + base = Graph (Map.map (const Map.empty) m) + +{- | Relabel by a potential @q@: @w_{i->j} <- w_{i->j} + q_j - q_i@. Nodes absent +from @q@ are treated as @0@. This leaves the sum of weights around every cycle +unchanged (the @q@ terms telescope). +-} +relabel :: (Ord n, Num w) => Map n w -> Graph n w -> Graph n w +relabel q (Graph m) = + Graph (Map.mapWithKey (\i dsts -> Map.mapWithKey (adjust i) dsts) m) + where + adjust i j w = w + qOf j - qOf i + qOf n = Map.findWithDefault 0 n q + +{- | Combine the weights of edges present in /both/ graphs with the given +function. The result's edge set is the intersection of the two edge sets; its +node set is the union of both node sets (so isolated nodes are preserved). +-} +zipEdgesWith :: + (Ord n) => (w -> w -> w') -> Graph n w -> Graph n w -> Graph n w' +zipEdgesWith f g1 g2 = L.foldl' step base (edges g1) + where + base = Graph (Map.fromList [(n, Map.empty) | n <- L.union (nodes g1) (nodes g2)]) + step acc (i, j, w1) = case weight i j g2 of + Just w2 -> addEdge i j (f w1 w2) acc + Nothing -> acc + +{- | Sum of edge weights around the closed cycle @n_0 -> n_1 -> ... -> n_k -> n_0@. +'Nothing' if any required edge is missing. The empty list sums to @0@. +-} +cycleSum :: (Ord n, Num w) => Graph n w -> [n] -> Maybe w +cycleSum _ [] = Just 0 +cycleSum g ns = sum <$> traverse (\(i, j) -> weight i j g) pairs + where + pairs = zip ns (drop 1 ns ++ take 1 ns) + +-- | Result of 'bellmanFord': either a witnessed negative cycle, or potentials. +data ShortestPaths n w + = NegativeCycle [n] + | Distances (Map n w) + deriving (Show, Eq) + +{- | Bellman-Ford from a virtual super-source: every node starts at distance @0@ +(this is /not/ single-source), so disconnected components are each handled +independently and every negative cycle is reachable. + +Returns 'Distances' @q@ satisfying @q_j - q_i <= w_{i->j}@ for every edge when no +negative cycle exists, otherwise a 'NegativeCycle' witness (the nodes on a +negative-weight cycle). +-} +bellmanFord :: (Ord n, Ord w, Num w) => Graph n w -> ShortestPaths n w +bellmanFord g = + case L.find relaxable es of + Nothing -> Distances dist + Just (_, j, _) -> NegativeCycle (extractCycle predecessors n j) + where + ns = nodes g + n = length ns + es = edges g + dist0 = Map.fromList [(v, 0) | v <- ns] + + -- \|V| - 1 relaxation passes. + (dist, predecessors) = + L.foldl' (\acc _ -> relaxOnce es acc) (dist0, Map.empty) [1 .. n - 1] + + -- An edge that can still be relaxed after |V|-1 passes lies downstream of a + -- negative cycle. + relaxable (i, j, w) = + case (Map.lookup i dist, Map.lookup j dist) of + (Just di, Just dj) -> di + w < dj + _ -> False + +-- | One relaxation pass over all edges, threading distances and predecessors. +relaxOnce :: + (Ord n, Ord w, Num w) => + [(n, n, w)] -> + (Map n w, Map n n) -> + (Map n w, Map n n) +relaxOnce es acc = L.foldl' step acc es + where + step (d, p) (i, j, w) = + case (Map.lookup i d, Map.lookup j d) of + (Just di, Just dj) + | di + w < dj -> (Map.insert j (di + w) d, Map.insert j i p) + _ -> (d, p) + +{- | Walk the predecessor chain @n@ steps to land on a negative cycle, then follow +it until it repeats. Returns the cycle nodes in forward (edge) order. +-} +extractCycle :: (Ord n) => Map n n -> Int -> n -> [n] +extractCycle predecessors n j = L.reverse (start : rest) + where + step v = Map.findWithDefault v v predecessors + start = applyN n step j + rest = takeWhile (/= start) (drop 1 (iterate step start)) + applyN k f x = iterate f x !! max 0 k + +-- | Potentials @q@ such that @q_j - q_i <= w_{i->j}@, or 'Nothing' on a negative cycle. +potentials :: (Ord n, Ord w, Num w) => Graph n w -> Maybe (Map n w) +potentials g = case bellmanFord g of + Distances q -> Just q + NegativeCycle _ -> Nothing + +-- | The nodes of a negative-weight cycle, if one exists. +negativeCycle :: (Ord n, Ord w, Num w) => Graph n w -> Maybe [n] +negativeCycle g = case bellmanFord g of + NegativeCycle c -> Just c + Distances _ -> Nothing diff --git a/bittide/src/Bittide/TimedReset.hs b/bittide/src/Bittide/TimedReset.hs new file mode 100644 index 0000000000..980ffb35aa --- /dev/null +++ b/bittide/src/Bittide/TimedReset.hs @@ -0,0 +1,74 @@ +-- SPDX-FileCopyrightText: 2026 Google LLC +-- +-- SPDX-License-Identifier: Apache-2.0 + +{- | +A small Wishbone peripheral that releases a reset at a software-chosen cycle. + +It holds its output reset asserted until the supplied local counter exceeds the +value in its single @release_cycle@ register, then deasserts it. The register is +written by the management unit (over the bus / via GDB). Because the local counter +is monotonic, once the reset is deasserted it stays deasserted. + +This is used for UGN-grooming relabeling: by choosing, per FPGA, the local-counter +value at which the application (user core) comes out of reset, each node's +application counter starts at a chosen origin — effectively relabeling the counters +to remove boot-time-induced offsets. See "Bittide.ClockControl.Ugn.Grooming". + +The @release_cycle@ register defaults to 'maxBound', so the reset stays asserted +until the management unit writes a (reachable) value — which it should only do once +clock control is stable. +-} +module Bittide.TimedReset (timedResetWb) where + +import Clash.Prelude +import Protocols + +import Clash.Class.BitPackC (ByteOrder) +import GHC.Stack (HasCallStack) +import Protocols.Experimental.Wishbone (Wishbone, WishboneMode (Standard)) +import Protocols.MemoryMap (Mm) +import Protocols.MemoryMap.Registers.WishboneStandard ( + deviceConfig, + deviceWbI, + registerConfig, + registerWbI, + ) + +{- | Timed reset peripheral. Deasserts its output reset once @localCounter@ is +greater than the value written to its @release_cycle@ register. +-} +timedResetWb :: + forall dom addrW nBytes. + ( HasCallStack + , HiddenClockResetEnable dom + , KnownNat addrW + , KnownNat nBytes + , 1 <= nBytes + , ?byteOrder :: ByteOrder + ) => + -- | Local counter + Signal dom (Unsigned 64) -> + Circuit + (ToConstBwd Mm, Wishbone dom 'Standard addrW nBytes) + ("RESET" ::: Reset dom) +timedResetWb localCounter = circuit $ \bus -> do + [wbReleaseCycle] <- deviceWbI (deviceConfig "TimedReset") -< bus + + (Fwd releaseCycle, _releaseCycleActivity) <- + registerWbI + ( registerConfig + "release_cycle" + "Local-counter value at which the output reset is released. While the\ + \ local counter is at or below this value the reset stays asserted; once\ + \ it exceeds it the reset deasserts and (being a monotonic counter) stays\ + \ deasserted. The management unit writes this to choose when the\ + \ application leaves reset (the UGN-grooming relabel). Defaults to\ + \ maxBound, i.e. held until written." + ) + (maxBound :: Unsigned 64) + -< (wbReleaseCycle, Fwd (pure Nothing)) + + let reset = unsafeFromActiveLow (localCounter .>. releaseCycle) + + idC -< Fwd reset diff --git a/bittide/tests/Tests/ClockControl/Ugn/Grooming.hs b/bittide/tests/Tests/ClockControl/Ugn/Grooming.hs new file mode 100644 index 0000000000..1af0c25a4c --- /dev/null +++ b/bittide/tests/Tests/ClockControl/Ugn/Grooming.hs @@ -0,0 +1,124 @@ +-- SPDX-FileCopyrightText: 2026 Google LLC +-- +-- SPDX-License-Identifier: Apache-2.0 + +module Tests.ClockControl.Ugn.Grooming where + +import Prelude + +import Bittide.ClockControl.Ugn.Grooming +import Bittide.Graph.Weighted (Graph, edges, fromEdges, negativeCycle, weight) + +import Data.Maybe (isNothing) +import Hedgehog +import Test.Tasty +import Test.Tasty.HUnit (Assertion, testCase, (@?=)) +import Test.Tasty.Hedgehog (testProperty) +import Test.Tasty.TH (testGroupGenerator) + +import qualified Data.Map.Strict as Map +import qualified Hedgehog.Gen as Gen +import qualified Hedgehog.Range as Range + +-- | Are these UGNs physically realisable? (No negative cycle on weights = @λ@.) +isAllowed :: (Ord n, Ord w, Num w) => Graph n w -> Bool +isAllowed = isNothing . negativeCycle + +-- | A directed 3-cycle (slide-5 style): allowed iff the cycle sum is nonnegative. +triangle :: Integer -> Integer -> Integer -> Graph Int Integer +triangle a b c = fromEdges [(0, 1, a), (1, 2, b), (2, 0, c)] + +{- | A bidirectional 3-node graph (slide-5 style): edges @0->1@, @1->2@, @2->0@ and +their reverses @1->0@, @2->1@, @0->2@. Allowed iff every cycle sum (both 2-cycles +and the two 3-cycles) is nonnegative. +-} +bidirTriangle :: + Integer -> Integer -> Integer -> Integer -> Integer -> Integer -> Graph Int Integer +bidirTriangle a b c d e f = + fromEdges [(0, 1, a), (1, 2, b), (2, 0, c), (1, 0, d), (2, 1, e), (0, 2, f)] + +case_triangle_allowed :: Assertion +case_triangle_allowed = isAllowed (triangle 1 1 1) @?= True + +case_triangle_allowed_mixed :: Assertion +case_triangle_allowed_mixed = isAllowed (triangle 5 (-2) (-1)) @?= True + +case_triangle_disallowed :: Assertion +case_triangle_disallowed = isAllowed (triangle (-1) (-1) (-1)) @?= False + +case_bidir_triangle_allowed :: Assertion +case_bidir_triangle_allowed = + -- Every 2-cycle sum (a+d, b+e, c+f) and both 3-cycles are >= 0. + isAllowed (bidirTriangle 0 1 0 1 0 1) @?= True + +case_bidir_triangle_disallowed :: Assertion +case_bidir_triangle_disallowed = + -- The 2-cycle 0<->1 sums to 1 + (-3) = -2 < 0. + isAllowed (bidirTriangle 1 1 1 (-3) 1 1) @?= False + +-- | Complete digraph over @0 .. k-1@ with the given per-edge weights. +completeFrom :: Int -> [Integer] -> Graph Int Integer +completeFrom k ws = fromEdges (zipWith (\(i, j) w -> (i, j, w)) (pairs k) ws) + +pairs :: Int -> [(Int, Int)] +pairs k = [(i, j) | i <- [0 .. k - 1], j <- [0 .. k - 1], i /= j] + +prop_groom_reaches_safe :: Property +prop_groom_reaches_safe = property $ do + k <- forAll $ Gen.int (Range.linear 2 5) + let ps = pairs k + n = length ps + lams <- forAll $ Gen.list (Range.singleton n) (Gen.integral (Range.linearFrom 0 (-50) 50)) + eps <- forAll $ Gen.list (Range.singleton n) (Gen.integral (Range.linear 0 20)) + let + measured = completeFrom k lams + safe = completeFrom k (zipWith (+) lams eps) + case groomCorrection measured safe of + Infeasible c -> annotateShow c >> failure + Feasible{correction = q, framesToInsert = frames} -> do + -- Every padding is nonnegative. + assert (all (\(_, _, f) -> f >= 0) frames) + -- Groom-then-pad reaches lambda^safe on every edge. + let qOf m = Map.findWithDefault 0 m q + frames === reaches measured safe q + mapM_ + ( \(i, j, f) -> do + lam <- maybe failure pure (weight i j measured) + safeW <- maybe failure pure (weight i j safe) + lam + qOf j - qOf i + f === safeW + ) + frames + where + reaches measured safe q = + [ (i, j, safeW - (lam + qf j - qf i)) + | (i, j, lam) <- edges measured + , Just safeW <- [weight i j safe] + ] + where + qf m = Map.findWithDefault 0 m q + +prop_idempotent :: Property +prop_idempotent = property $ do + -- lambda == lambda^safe: feasible with all-zero frames (no relabeling needed). + k <- forAll $ Gen.int (Range.linear 2 5) + let ps = pairs k + n = length ps + lams <- forAll $ Gen.list (Range.singleton n) (Gen.integral (Range.linearFrom 0 (-50) 50)) + let g = completeFrom k lams + case groomCorrection g g of + Infeasible c -> annotateShow c >> failure + Feasible{framesToInsert = frames} -> + assert (all (\(_, _, f) -> f == 0) frames) + +case_infeasible_when_changed_too_much :: Assertion +case_infeasible_when_changed_too_much = do + -- safe requires a cycle sum that is smaller than measured's: infeasible. + let + measured = triangle 0 0 0 -- cycle sum 0 + safe = triangle (-1) (-1) (-1) -- would need cycle sum -3 >= 0: impossible + case groomCorrection measured safe of + Infeasible _ -> pure () + Feasible{} -> fail "expected Infeasible" + +tests :: TestTree +tests = $(testGroupGenerator) diff --git a/bittide/tests/Tests/Graph/Weighted.hs b/bittide/tests/Tests/Graph/Weighted.hs new file mode 100644 index 0000000000..4249813ecd --- /dev/null +++ b/bittide/tests/Tests/Graph/Weighted.hs @@ -0,0 +1,122 @@ +-- SPDX-FileCopyrightText: 2026 Google LLC +-- +-- SPDX-License-Identifier: Apache-2.0 + +module Tests.Graph.Weighted where + +import Prelude + +import Bittide.Graph.Weighted + +import Data.List (sort) +import Data.Maybe (isJust) +import Hedgehog +import Test.Tasty +import Test.Tasty.HUnit (Assertion, testCase, (@?=)) +import Test.Tasty.Hedgehog (testProperty) +import Test.Tasty.TH (testGroupGenerator) + +import qualified Data.Map.Strict as Map +import qualified Hedgehog.Gen as Gen +import qualified Hedgehog.Range as Range + +-- | A small directed weighted graph used across several tests. +sampleEdges :: [(Int, Int, Int)] +sampleEdges = [(0, 1, 5), (1, 2, 3), (2, 0, 1), (0, 2, 9)] + +sampleGraph :: Graph Int Int +sampleGraph = fromEdges sampleEdges + +case_fromEdges_edges_roundtrip :: Assertion +case_fromEdges_edges_roundtrip = sort (edges sampleGraph) @?= sort sampleEdges + +case_counts :: Assertion +case_counts = (nodeCount sampleGraph, edgeCount sampleGraph) @?= (3, 4) + +case_addEdge_overwrites :: Assertion +case_addEdge_overwrites = + weight 0 1 (addEdge 0 1 42 sampleGraph) @?= Just 42 + +case_outgoing_incoming :: Assertion +case_outgoing_incoming = do + sort (outgoing 0 sampleGraph) @?= [(1, 5), (2, 9)] + sort (incoming 0 sampleGraph) @?= [(2, 1)] + +case_weight_hasEdge :: Assertion +case_weight_hasEdge = do + weight 1 2 sampleGraph @?= Just 3 + weight 2 1 sampleGraph @?= Nothing + hasEdge 2 0 sampleGraph @?= True + hasEdge 0 0 sampleGraph @?= False + +case_isolated_node_survives_transpose :: Assertion +case_isolated_node_survives_transpose = + -- An edge 0 -> 1 makes node 1 a sink; after transpose it must still appear. + sort (nodes (transpose (fromEdges [(0 :: Int, 1 :: Int, 7 :: Int)]))) @?= [0, 1] + +-- | Generate a random directed graph over nodes @0 .. k-1@ (no self loops). +genGraph :: Gen (Graph Int Int) +genGraph = do + k <- Gen.int (Range.linear 1 6) + let allPairs = [(i, j) | i <- [0 .. k - 1], j <- [0 .. k - 1], i /= j] + chosen <- Gen.subsequence allPairs + fmap fromEdges $ + traverse + (\(i, j) -> (\w -> (i, j, w)) <$> Gen.int (Range.linearFrom 0 (-50) 50)) + chosen + +prop_transpose_involution :: Property +prop_transpose_involution = property $ do + g <- forAll genGraph + transpose (transpose g) === g + +prop_relabel_preserves_cycle_sums :: Property +prop_relabel_preserves_cycle_sums = property $ do + -- A fixed 3-cycle whose every edge exists, with random weights and potentials. + let cyc = [0, 1, 2] :: [Int] + ws <- forAll $ Gen.list (Range.singleton 3) (Gen.int (Range.linearFrom 0 (-50) 50)) + let g = fromEdges [(0, 1, ws !! 0), (1, 2, ws !! 1), (2, 0, ws !! 2)] + qs <- forAll $ Gen.list (Range.singleton 3) (Gen.int (Range.linearFrom 0 (-50) 50)) + let q = Map.fromList (zip [0, 1, 2] qs) + cycleSum (relabel q g) cyc === cycleSum g cyc + +case_negative_cycle_detected :: Assertion +case_negative_cycle_detected = do + let g = fromEdges [(0 :: Int, 1 :: Int, -1 :: Int), (1, 2, -1), (2, 0, -1)] + case negativeCycle g of + Nothing -> fail "expected a negative cycle" + Just c -> do + -- The witness is a genuine cycle, and its sum is negative. + sort c @?= [0, 1, 2] + ((< 0) <$> cycleSum g c) @?= Just True + isAllowedSanity g @?= False + where + -- mirror of Grooming.isAllowed at the graph level + isAllowedSanity gr = case negativeCycle gr of Nothing -> True; Just _ -> False + +case_positive_cycle_has_potentials :: Assertion +case_positive_cycle_has_potentials = do + let g = fromEdges [(0 :: Int, 1 :: Int, 1 :: Int), (1, 2, 1), (2, 0, 1)] + isJust (potentials g) @?= True + +case_disconnected_graph_has_potentials :: Assertion +case_disconnected_graph_has_potentials = do + -- Two independent components; the super-source handles each one. + let g = fromEdges [(0 :: Int, 1 :: Int, 3 :: Int), (10, 11, -4), (11, 10, 9)] + case potentials g of + Nothing -> fail "expected potentials to exist" + Just q -> sort (Map.keys q) @?= [0, 1, 10, 11] + +case_zipEdgesWith_intersection :: Assertion +case_zipEdgesWith_intersection = do + let + a = fromEdges [(0 :: Int, 1 :: Int, 10 :: Int), (1, 2, 20)] + b = fromEdges [(0, 1, 1), (2, 0, 99)] + c = zipEdgesWith (-) a b + -- Only the shared edge 0 -> 1 survives, with combined weight. + sort (edges c) @?= [(0, 1, 9)] + -- Node set is the union. + sort (nodes c) @?= [0, 1, 2] + +tests :: TestTree +tests = $(testGroupGenerator) diff --git a/bittide/tests/Tests/TimedReset.hs b/bittide/tests/Tests/TimedReset.hs new file mode 100644 index 0000000000..e2047b57f2 --- /dev/null +++ b/bittide/tests/Tests/TimedReset.hs @@ -0,0 +1,85 @@ +-- SPDX-FileCopyrightText: 2026 Google LLC +-- +-- SPDX-License-Identifier: Apache-2.0 +{-# LANGUAGE OverloadedStrings #-} + +module Tests.TimedReset where + +import Clash.Prelude +import Protocols + +import Bittide.SharedTypes (withByteOrder) +import Bittide.TimedReset (timedResetWb) +import Clash.Class.BitPackC (ByteOrder (..)) +import Clash.Class.BitPackC.Words (packWordCI) +import Data.Maybe (fromJust) +import Protocols.Experimental.Hedgehog (defExpectOptions) +import Protocols.Experimental.Simulate (SimulationConfig (..), sampleC) +import Protocols.Experimental.Wishbone +import Protocols.Experimental.Wishbone.Standard.Hedgehog (WishboneMasterRequest (..)) +import Protocols.MemoryMap + +import Clash.Hedgehog.Sized.Unsigned (genUnsigned) +import Hedgehog (Property) +import Test.Tasty +import Test.Tasty.Hedgehog (testProperty) +import Test.Tasty.TH (testGroupGenerator) + +import qualified Data.List as L +import qualified Data.Map as Map +import qualified Data.String.Interpolate as Str +import qualified Hedgehog as H +import qualified Hedgehog.Gen as Gen +import qualified Hedgehog.Range as Range +import qualified Protocols.Experimental.Wishbone.Standard.Hedgehog as Wb + +{- | Writing @release_cycle = N@ must make the reset deassert exactly when the local +counter exceeds @N@: at every cycle the reset is asserted iff @counter <= N@. +-} +prop_timedReset_deasserts_after_release_cycle :: Property +prop_timedReset_deasserts_after_release_cycle = H.property $ do + -- Lower bound of 100 ensures the register write lands before the counter reaches it. + releaseCycle <- H.forAll $ genUnsigned @64 (Range.linear 100 (fromIntegral simLength - 20)) + byteOrder <- H.forAll $ Gen.element [BigEndian, LittleEndian] + withByteOrder byteOrder $ do + let + -- 8-byte data width so the 64-bit release_cycle is written in one request. + dut :: + Circuit + (ToConstBwd Mm, Wishbone System 'Standard 32 8) + (CSignal System (Unsigned 64), CSignal System Bool) + dut = + withClockResetEnable @System clockGen resetGen enableGen + $ circuit + $ \wb -> do + let counter = register 0 (counter + 1) + Fwd rst <- timedResetWb counter -< wb + idC -< (Fwd counter, Fwd (unsafeToActiveHigh rst)) + + deviceName = "TimedReset" + defs = (getMMAny dut).deviceDefs Map.! deviceName + releaseCycleLoc = L.find (\loc -> loc.name.name == "release_cycle") defs.registers + releaseCycleAddr = fromIntegral (fromJust releaseCycleLoc).value.address `div` 8 + + releaseCycleBv = pack $ packWordCI @8 releaseCycle + requests = fmap (,0) [Write releaseCycleAddr maxBound releaseCycleBv] + + (counters, resets) = + sampleC + def{timeoutAfter = simLength} + (unMemmap dut <| Wb.driveStandard defExpectOptions requests) + + -- The reset must be asserted (active-high True) exactly while counter <= N. + mismatches = + [ (c, r) + | (c, r) <- L.zip counters resets + , r /= (c <= releaseCycle) + ] + + H.footnote [Str.i|release_cycle = #{releaseCycle}; first mismatches: #{L.take 5 mismatches}|] + L.null mismatches H.=== True + where + simLength = 200 + +tests :: TestTree +tests = $(testGroupGenerator) diff --git a/bittide/tests/UnitTests.hs b/bittide/tests/UnitTests.hs index 8d4859540a..6a39e04433 100644 --- a/bittide/tests/UnitTests.hs +++ b/bittide/tests/UnitTests.hs @@ -18,13 +18,16 @@ import qualified Tests.CaptureUgn import qualified Tests.Clash.Protocols.Wishbone.Extra -- Should be moved to bittide-extra, but depends on wbStorage import qualified Tests.ClockControl.Freeze import qualified Tests.ClockControl.Si539xSpi +import qualified Tests.ClockControl.Ugn.Grooming import qualified Tests.DelayWishbone import qualified Tests.Df import qualified Tests.DoubleBufferedRam import qualified Tests.ElasticBuffer +import qualified Tests.Graph.Weighted import qualified Tests.Handshake import qualified Tests.ProcessingElement.ReadElf import qualified Tests.ProgrammableMux +import qualified Tests.TimedReset import qualified Tests.Transceiver import qualified Tests.Transceiver.Prbs import qualified Tests.Transceiver.WordAlign @@ -45,13 +48,16 @@ tests = , Tests.Clash.Protocols.Wishbone.Extra.tests , Tests.ClockControl.Freeze.tests , Tests.ClockControl.Si539xSpi.tests + , Tests.ClockControl.Ugn.Grooming.tests , Tests.DelayWishbone.tests , Tests.Df.tests , Tests.DoubleBufferedRam.tests , Tests.ElasticBuffer.tests + , Tests.Graph.Weighted.tests , Tests.Handshake.tests , Tests.ProcessingElement.ReadElf.tests , Tests.ProgrammableMux.tests + , Tests.TimedReset.tests , Tests.Transceiver.Prbs.tests , Tests.Transceiver.tests , Tests.Transceiver.WordAlign.tests diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index e79a965e47..80917ffb0d 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -16,5 +16,6 @@ SPDX-License-Identifier: Apache-2.0 - [Demos]() - [Soft UGN Demo](sections/demos/soft-ugn-demo.md) - [Wire Demo](sections/demos/wire-demo.md) +- [Restoring a known state (UGN grooming)](sections/ugn-grooming.md) - [Ring Buffer alignment](sections/ringbuffer-alignment.md) - [Asynchronous communication](sections/asynchronous-communication.md) diff --git a/docs/sections/ugn-grooming.md b/docs/sections/ugn-grooming.md new file mode 100644 index 0000000000..091d5d5a37 --- /dev/null +++ b/docs/sections/ugn-grooming.md @@ -0,0 +1,180 @@ + + +# Restoring a known state (UGN grooming) + +A bittide application that wants a *fixed* schedule — the same per-link timing on +every run — needs the network to look the same on every boot. It does not. This +chapter explains how we restore a freshly-booted system to a stored, known-good +state so that a single precomputed schedule keeps working. We call this procedure +**UGN grooming**. + +## The problem + +Every link carries a [UGN](architecture.md): a signed clock/frame offset +`λ(i→j) = localCounter − remoteCounter`, measured between an ordered pair of nodes +`i → j`. The UGNs describe the network's latency profile, and the application's +schedule is derived from them. + +Two facts make a naive "measure once, schedule forever" approach fail: + +1. **Boot-time offsets.** Each node's local counter starts from an arbitrary value + that depends on when its reset was released. Those offsets leak into the + measured UGNs, so the raw UGNs differ — sometimes by hundreds of thousands of + cycles — from one boot to the next, even though the physical network is + unchanged. +2. **The round-trip constraint.** UGNs cannot be chosen independently. The sum of + the UGNs around any directed cycle is the round-trip latency of that cycle and + must be non-negative (you cannot receive a frame before it was sent). A set of + UGNs is *physically realisable* exactly when its graph has **no negative + cycle**. + +So we cannot just overwrite the new UGNs with the old ones; we must transform this +boot's measurements back onto the stored reference *while respecting the round-trip +constraints*. + +## UGNs as a weighted graph + +Model the network as a directed graph: nodes are FPGAs (keyed by their DNA-derived +id) and every link `i → j` is an edge weighted by its UGN `λ(i→j)`. This is the +`Bittide.Graph.Weighted` library (`bittide/src/Bittide/Graph/Weighted.hs`), +which provides Bellman-Ford shortest paths, node potentials, and negative-cycle +detection. The grooming math is then a small layer on top of it, in +`Bittide.ClockControl.Ugn.Grooming` (`bittide/src/Bittide/ClockControl/Ugn/Grooming.hs`). + +We keep one stored reference profile, `λsafe`. It is a previously-captured boot's +UGNs plus a small safety margin `ε`: + +``` +λsafe(i→j) = λobs(i→j) + ε +``` + +The margin is the headroom the elastic buffers are allowed to insert on top of +matching the reference latency; it absorbs the small boot-to-boot physical drift +while staying well within the buffer's safe occupancy range. + +## The grooming algorithm + +Given this boot's measured UGNs `λ` and the stored `λsafe`, we want two things: + +* a **relabeling** `q` — one integer per node — that shifts each node's time base, + and +* a small number of **frames to insert** per link, + +such that after applying both, every link's UGN equals `λsafe(i→j)` again. A +relabeling by `q` changes a link's UGN to `λ(i→j) + q(j) − q(i)`: it is a pure +gauge change (it cancels around every cycle), which is exactly why it can undo +the per-node boot offsets without touching the physical round-trip latencies. + +We want the relabeled UGN to fit *under* the target, leaving a non-negative +remainder to pad with inserted frames: + +``` +λ(i→j) + q(j) − q(i) ≤ λsafe(i→j) for every link i → j +``` + +Rearranging gives a **system of difference constraints** on the unknown potentials +`q`, with the per-link *slack* as the bound: + +``` +slack: c(i→j) = λsafe(i→j) − λ(i→j) +constraint: q(j) − q(i) ≤ c(i→j) +``` + +This is a classic shortest-path problem. Build the **slack graph** (the same edges, +reweighted by `c`), add a virtual super-source with a zero-weight edge to every +node, and run **Bellman-Ford**. Two outcomes: + +* **Negative cycle in the slack graph → infeasible.** The UGNs changed too much to + be restored to `λsafe`; grooming reports the witnessing cycle for diagnostics and + gives up. +* **Otherwise, the shortest-path distances are the potentials `q`.** By the + shortest-path relaxation invariant they satisfy every constraint, so the leftover + padding is guaranteed non-negative: + +``` +frames(i→j) = λsafe(i→j) − (λ(i→j) + q(j) − q(i)) ≥ 0 +``` + +`q` and `frames` together are the restore plan: relabel by `q`, then insert +`frames(i→j)` frames on each receiving link, and every UGN is back at `λsafe`. + +## Applying the plan to hardware + +The host computes the plan and pushes it to each node over GDB. The plan has two +halves, applied through two dedicated Wishbone peripherals. + +### Relabeling — `TimedReset` + +A relabeling `q` is realised by *when* each node's application leaves reset. The +`TimedReset` (`bittide/src/Bittide/TimedReset.hs`) peripheral holds the +application reset asserted until the node's local counter passes a software-chosen +`release_cycle`, then releases it (and, because the counter is monotonic, keeps it +released). The application counter is therefore `0` at that release point, which +relabels the node's time base. The host writes, gauged so node `0` is the origin: + +``` +release_cycle(i) = base − (q(i) − q(0)) +``` + +### Frame corrections — `UgnCorrections` + MU firmware + +The per-link `frames` are written to the `UgnCorrections` peripheral +(`bittide/src/Bittide/ClockControl/Ugn/Corrections.hs`) — a corrections vector plus +a `valid` flag. The management-unit firmware `wire-demo-management-unit` +(`firmware-binaries/demos/wire-demo-management-unit/src/main.rs`) +polls `valid`, then applies each link's correction to its elastic buffer as a +**single atomic write**. A frame-at-a-time loop is vulnerable to concurrent clock-control +(Callisto) adjustments between iterations, which shrink the net change; one atomic +write bounds that race to at most one frame. + +The host orchestrates the ordering: corrections are applied while the application +is still held in reset, then the relabel-release cycles are written, so the +application starts in the fully-restored state. + +## Storing `λsafe` in a canonical gauge + +`λsafe` is stored in a **minimal non-negative gauge** (`canonicalizeUgn` in +`Bittide.Instances.Hitl.Utils.UgnGrooming`, +`bittide-instances/src/Bittide/Instances/Hitl/Utils/UgnGrooming.hs`). +Using the graph's potentials, every UGN is rewritten to its Bellman-Ford *reduced +cost* `λ(i→j) + q(i) − q(j)`, which is non-negative and leaves every cycle sum +unchanged. This is again a pure gauge change — the *same* physical system — but in +a gauge where the per-link UGNs are small and non-negative. + +Why bother: the application's fixed schedule is derived from these UGNs, and the +programmable mux / elastic buffers only work over a bounded range. Grooming each +boot onto a canonical `λsafe` means the relabel absorbs that boot's large counter +offsets and the groomed app-frame UGNs land back on the same small, non-negative +values every run — which is precisely what makes one precomputed schedule reusable. +Note that canonicalization honours the true *asymmetric* round-trip constraints; a +naive symmetric midpoint is only a valid relabeling on an acyclic graph. + +## Verifying it landed + +To confirm on hardware that the relabel took effect, the wire-demo user core +exposes its application counter through an `app_counter` readback register. With +the application running, the host checks per node that + +``` +localCounter − app_counter == release_cycle +``` + +A divergence pinpoints a node whose reset did not release at the intended +(relabeled) cycle. + +## Components involved + +| Component | Location | Role | +|-----------|----------|------| +| Weighted graph library | `Bittide.Graph.Weighted` (`bittide/src/Bittide/Graph/Weighted.hs`) | Bellman-Ford, potentials, negative-cycle detection | +| Grooming algorithm | `Bittide.ClockControl.Ugn.Grooming` (`bittide/src/Bittide/ClockControl/Ugn/Grooming.hs`) | `λ` + `λsafe` → relabel `q` + frames, or infeasible | +| HITL grooming layer | `Utils.UgnGrooming` (`bittide-instances/src/Bittide/Instances/Hitl/Utils/UgnGrooming.hs`) | adapts the algorithm to `UgnEdge`s, canonical gauge, reset offsets | +| `TimedReset` peripheral | `Bittide.TimedReset` (`bittide/src/Bittide/TimedReset.hs`) | releases the app reset at the chosen cycle (the relabel) | +| `UgnCorrections` peripheral | `Bittide.ClockControl.Ugn.Corrections` (`bittide/src/Bittide/ClockControl/Ugn/Corrections.hs`) | host → MU channel for the per-link frame corrections | +| MU firmware | `wire-demo-management-unit` (`firmware-binaries/demos/wire-demo-management-unit/src/main.rs`) | applies the corrections to the elastic buffers | + +This procedure is exercised end-to-end by the [Wire Demo](demos/wire-demo.md). diff --git a/firmware-binaries/Cargo.lock b/firmware-binaries/Cargo.lock index 96905b10dc..9471552d08 100644 --- a/firmware-binaries/Cargo.lock +++ b/firmware-binaries/Cargo.lock @@ -1012,6 +1012,7 @@ dependencies = [ "bittide-build-utils", "bittide-hal", "bittide-sys", + "clash-bindings", "riscv-rt 0.11.0", "ufmt", ] diff --git a/firmware-binaries/demos/wire-demo-management-unit/Cargo.toml b/firmware-binaries/demos/wire-demo-management-unit/Cargo.toml index 50423c407e..0511ac8333 100644 --- a/firmware-binaries/demos/wire-demo-management-unit/Cargo.toml +++ b/firmware-binaries/demos/wire-demo-management-unit/Cargo.toml @@ -10,6 +10,7 @@ license = "Apache-2.0" authors = ["Google LLC"] [dependencies] +clash-bindings = { git = "https://github.com/QBayLogic/clash-protocols-memmap", rev = "745be2eff82fd8d712b0e50d825837d2f39fb920" } riscv-rt = "0.11.0" bittide-sys = { path = "../../../firmware-support/bittide-sys" } ufmt = "0.2.0" diff --git a/firmware-binaries/demos/wire-demo-management-unit/src/main.rs b/firmware-binaries/demos/wire-demo-management-unit/src/main.rs index 4c7163723c..ff1e7b74e4 100644 --- a/firmware-binaries/demos/wire-demo-management-unit/src/main.rs +++ b/firmware-binaries/demos/wire-demo-management-unit/src/main.rs @@ -8,6 +8,7 @@ use bittide_hal::hals::wire_demo_management_unit::DeviceInstances; use bittide_sys::link_startup::LinkStartup; use bittide_sys::stability_detector::Stability; +use clash_bindings::signed::Signed; use core::panic::PanicInfo; use ufmt::uwriteln; @@ -83,6 +84,37 @@ fn main() -> ! { } uwriteln!(uart, "Printed all hardware UGNs").unwrap(); + // === UGN grooming: poll for host-computed corrections and apply them === + // The host reads the UGNs, computes the corrections, halts this CPU over GDB, + // writes the corrections vector + sets `valid`, and resumes us. We then apply + // each correction to its link's elastic buffer in one atomic adjustment per + // link. Using a single adjustment (rather than a per-frame loop) avoids the + // race where Callisto's concurrent adjustments partially undo the correction. + let corrections = INSTANCES.ugn_corrections; + uwriteln!(uart, "Waiting for corrections...").unwrap(); + while !corrections.valid() { + core::hint::spin_loop(); + } + uwriteln!(uart, "Corrections valid, applying...").unwrap(); + + for (i, eb) in elastic_buffers.iter().enumerate() { + let target = corrections.corrections(i).unwrap().into_inner(); + let before = eb.data_count().into_inner() as i64; + eb.set_adjustment(Signed::<32, i32>::new(target as i32).unwrap()); + let after = eb.data_count().into_inner() as i64; + uwriteln!( + uart, + "Correction link {}: target = {}, data_count {} -> {}", + i, + target, + before, + after + ) + .unwrap(); + } + + uwriteln!(uart, "Corrections applied successfully").unwrap(); + #[allow(clippy::empty_loop)] loop {} }