From af76e3c42270591749067edc51d78cb70056a263 Mon Sep 17 00:00:00 2001
From: Simon Bjurek <simbj106@student.liu.se>
Date: Tue, 15 Apr 2025 13:05:45 +0200
Subject: [PATCH] Add ILP split_on_execution_time method

---
 b_asic/architecture.py                       |  10 +-
 b_asic/resources.py                          | 116 +++++++++++++++++--
 examples/memory_constrained_scheduling.py    |   4 +-
 test/integration/test_sfg_to_architecture.py |  26 ++---
 test/unit/test_list_schedulers.py            |   4 +-
 test/unit/test_resources.py                  |  12 +-
 6 files changed, 141 insertions(+), 31 deletions(-)

diff --git a/b_asic/architecture.py b/b_asic/architecture.py
index 7f0722a4..9ceaacc7 100644
--- a/b_asic/architecture.py
+++ b/b_asic/architecture.py
@@ -561,7 +561,12 @@ class Memory(Resource):
                 pass
         return ""
 
-    def assign(self, strategy: str = "left_edge") -> None:
+    def assign(
+        self,
+        strategy: Literal[
+            "left_edge", "greedy_graph_color", "ilp_graph_color"
+        ] = "left_edge",
+    ) -> None:
         """
         Perform assignment of the memory variables.
 
@@ -573,7 +578,8 @@ class Memory(Resource):
 
             * 'RAM'
                 * 'left_edge': Left-edge algorithm.
-                * 'graph_color': Graph-coloring based on exclusion graph.
+                * 'greedy_graph_color': Greedy graph-coloring based on exclusion graph.
+                * 'ilp_graph_color': Optimal graph-coloring based on exclusion graph.
             * 'register'
                 * ...
         """
diff --git a/b_asic/resources.py b/b_asic/resources.py
index e09af3ad..95b42d82 100644
--- a/b_asic/resources.py
+++ b/b_asic/resources.py
@@ -913,21 +913,27 @@ class ProcessCollection:
 
     def split_on_execution_time(
         self,
-        strategy: Literal["graph_color", "left_edge"] = "left_edge",
+        strategy: Literal[
+            "left_edge",
+            "greedy_graph_color",
+            "ilp_graph_color",
+        ] = "left_edge",
         coloring_strategy: str = "saturation_largest_first",
+        max_colors: int | None = None,
+        solver: PULP_CBC_CMD | GUROBI | None = None,
     ) -> list["ProcessCollection"]:
         """
         Split based on overlapping execution time.
 
         Parameters
         ----------
-        strategy : {'graph_color', 'left_edge'}, default: 'left_edge'
+        strategy : {'ilp_graph_color', 'greedy_graph_color', 'left_edge'}, default: 'left_edge'
             The strategy used when splitting based on execution times.
 
         coloring_strategy : str, default: 'saturation_largest_first'
             Node ordering strategy passed to
             :func:`networkx.algorithms.coloring.greedy_color`.
-            This parameter is only considered if *strategy* is set to 'graph_color'.
+            This parameter is only considered if *strategy* is set to 'greedy_graph_color'.
             One of
 
             * 'largest_first'
@@ -938,12 +944,24 @@ class ProcessCollection:
             * 'connected_sequential_dfs' or 'connected_sequential'
             * 'saturation_largest_first' or 'DSATUR'
 
+        max_colors : int, optional
+            The maximum amount of colors to split based on,
+            only required if strategy is an ILP method.
+
+        solver : PuLP MIP solver object, optional
+            Only used if strategy is an ILP method.
+            Valid options are:
+                * PULP_CBC_CMD() - preinstalled with the package
+                * GUROBI() - required licence but likely faster
+
         Returns
         -------
         A list of new ProcessCollection objects with the process splitting.
         """
-        if strategy == "graph_color":
-            return self._graph_color_assignment(coloring_strategy)
+        if strategy == "ilp_graph_color":
+            return self._ilp_graph_color_assignment(max_colors, solver)
+        elif strategy == "greedy_graph_color":
+            return self._greedy_graph_color_assignment(coloring_strategy)
         elif strategy == "left_edge":
             return self._left_edge_assignment()
         else:
@@ -1841,7 +1859,92 @@ class ProcessCollection:
     def __iter__(self):
         return iter(self._collection)
 
-    def _graph_color_assignment(
+    def _ilp_graph_color_assignment(
+        self,
+        max_colors: int | None = None,
+        solver: PULP_CBC_CMD | GUROBI | None = None,
+    ) -> list["ProcessCollection"]:
+        for process in self:
+            if process.execution_time > self.schedule_time:
+                raise ValueError(
+                    f"{process} has execution time greater than the schedule time"
+                )
+
+        cell_assignment: dict[int, ProcessCollection] = {}
+        exclusion_graph = self.create_exclusion_graph_from_execution_time()
+
+        nodes = list(exclusion_graph.nodes())
+        edges = list(exclusion_graph.edges())
+
+        if max_colors is None:
+            # get an initial estimate using NetworkX greedy graph coloring
+            coloring = nx.coloring.greedy_color(
+                exclusion_graph, strategy="saturation_largest_first"
+            )
+            max_colors = len(set(coloring.values()))
+        colors = range(max_colors)
+
+        # find the minimal amount of colors (memories)
+
+        # binary variables:
+        #   x[node, color] - whether node is colored in a certain color
+        #   c[color] - whether color is used
+        x = LpVariable.dicts("x", (nodes, colors), cat=LpBinary)
+        c = LpVariable.dicts("c", colors, cat=LpBinary)
+        problem = LpProblem()
+        problem += lpSum(c[i] for i in colors)
+
+        # constraints:
+        #   (1) - nodes have exactly one color
+        #   (2) - adjacent nodes cannot have the same color
+        #   (3) - only permit assignments if color is used
+        #   (4) - reduce solution space by assigning colors to the largest clique
+        #   (5 & 6) - reduce solution space by ignoring the symmetry caused
+        #       by cycling the graph colors
+        for node in nodes:
+            problem += lpSum(x[node][i] for i in colors) == 1
+        for u, v in edges:
+            for color in colors:
+                problem += x[u][color] + x[v][color] <= 1
+        for node in nodes:
+            for color in colors:
+                problem += x[node][color] <= c[color]
+        max_clique = next(nx.find_cliques(exclusion_graph))
+        for color, node in enumerate(max_clique):
+            problem += x[node][color] == c[color] == 1
+        for color in colors:
+            problem += c[color] <= lpSum(x[node][color] for node in nodes)
+        for color in colors[:-1]:
+            problem += c[color + 1] <= c[color]
+
+        if solver is None:
+            solver = PULP_CBC_CMD()
+
+        status = problem.solve(solver)
+
+        if status != LpStatusOptimal:
+            raise ValueError(
+                "Optimal solution could not be found via ILP, use another method."
+            )
+
+        node_colors = {}
+        for node in nodes:
+            for i in colors:
+                if value(x[node][i]) == 1:
+                    node_colors[node] = i
+
+        # reduce the solution by removing unused colors
+        sorted_unique_values = sorted(set(node_colors.values()))
+        coloring_mapping = {val: i for i, val in enumerate(sorted_unique_values)}
+        coloring = {key: coloring_mapping[node_colors[key]] for key in node_colors}
+
+        for process, cell in coloring.items():
+            if cell not in cell_assignment:
+                cell_assignment[cell] = ProcessCollection([], self._schedule_time)
+            cell_assignment[cell].add_process(process)
+        return list(cell_assignment.values())
+
+    def _greedy_graph_color_assignment(
         self,
         coloring_strategy: str = "saturation_largest_first",
         *,
@@ -1869,7 +1972,6 @@ class ProcessCollection:
         """
         for process in self:
             if process.execution_time > self.schedule_time:
-                # Can not assign process to any cell
                 raise ValueError(
                     f"{process} has execution time greater than the schedule time"
                 )
diff --git a/examples/memory_constrained_scheduling.py b/examples/memory_constrained_scheduling.py
index fe87b123..d7fc95e8 100644
--- a/examples/memory_constrained_scheduling.py
+++ b/examples/memory_constrained_scheduling.py
@@ -73,7 +73,7 @@ for i, mem in enumerate(mem_vars_set):
     memory = Memory(mem, memory_type="RAM", entity_name=f"memory{i}")
     memories.append(memory)
     mem.show(title=f"{memory.entity_name}")
-    memory.assign("graph_color")
+    memory.assign("greedy_graph_color")
     memory.show_content(title=f"Assigned {memory.entity_name}")
 
 direct.show(title="Direct interconnects")
@@ -130,7 +130,7 @@ for i, mem in enumerate(mem_vars_set):
     memory = Memory(mem, memory_type="RAM", entity_name=f"memory{i}")
     memories.append(memory)
     mem.show(title=f"{memory.entity_name}")
-    memory.assign("graph_color")
+    memory.assign("greedy_graph_color")
     memory.show_content(title=f"Assigned {memory.entity_name}")
 
 direct.show(title="Direct interconnects")
diff --git a/test/integration/test_sfg_to_architecture.py b/test/integration/test_sfg_to_architecture.py
index 7ac22550..821cdcfd 100644
--- a/test/integration/test_sfg_to_architecture.py
+++ b/test/integration/test_sfg_to_architecture.py
@@ -72,7 +72,7 @@ def test_pe_constrained_schedule():
     # for i, mem in enumerate(mem_vars_set):
     #     memory = Memory(mem, memory_type="RAM", entity_name=f"memory{i}")
     #     memories.append(memory)
-    #     memory.assign("graph_color")
+    #     memory.assign("greedy_graph_color")
 
     # arch = Architecture(
     #     {mads0, mads1, reciprocal_pe, pe_in, pe_out},
@@ -137,7 +137,7 @@ def test_pe_and_memory_constrained_schedule():
     for i, mem in enumerate(mem_vars_set):
         memory = Memory(mem, memory_type="RAM", entity_name=f"memory{i}")
         memories.append(memory)
-        memory.assign("graph_color")
+        memory.assign("greedy_graph_color")
 
     arch = Architecture(
         {bf_pe, mul_pe, pe_in, pe_out},
@@ -172,7 +172,7 @@ def test_left_edge(mem_variables_fft32):
     for i, mem in enumerate(mem_vars_set):
         memory = Memory(mem, memory_type="RAM", entity_name=f"memory{i}")
         memories.append(memory)
-        memory.assign("graph_color")
+        memory.assign("greedy_graph_color")
 
     arch = Architecture(
         processing_elements,
@@ -199,7 +199,7 @@ def test_min_pe_to_mem(mem_variables_fft32):
     for i, mem in enumerate(mem_vars_set):
         memory = Memory(mem, memory_type="RAM", entity_name=f"memory{i}")
         memories.append(memory)
-        memory.assign("graph_color")
+        memory.assign("greedy_graph_color")
 
     arch = Architecture(
         processing_elements,
@@ -226,7 +226,7 @@ def test_min_mem_to_pe(mem_variables_fft32):
     for i, mem in enumerate(mem_vars_set):
         memory = Memory(mem, memory_type="RAM", entity_name=f"memory{i}")
         memories.append(memory)
-        memory.assign("graph_color")
+        memory.assign("greedy_graph_color")
 
     arch = Architecture(
         processing_elements,
@@ -253,7 +253,7 @@ def test_greedy_graph_coloring(mem_variables_fft32):
     for i, mem in enumerate(mem_vars_set):
         memory = Memory(mem, memory_type="RAM", entity_name=f"memory{i}")
         memories.append(memory)
-        memory.assign("graph_color")
+        memory.assign("greedy_graph_color")
 
     arch = Architecture(
         processing_elements,
@@ -280,7 +280,7 @@ def test_equitable_color(mem_variables_fft32):
     for i, mem in enumerate(mem_vars_set):
         memory = Memory(mem, memory_type="RAM", entity_name=f"memory{i}")
         memories.append(memory)
-        memory.assign("graph_color")
+        memory.assign("greedy_graph_color")
 
     arch = Architecture(
         processing_elements,
@@ -306,7 +306,7 @@ def test_ilp_color(mem_variables_fft16):
     for i, mem in enumerate(mem_vars_set):
         memory = Memory(mem, memory_type="RAM", entity_name=f"memory{i}")
         memories.append(memory)
-        memory.assign("graph_color")
+        memory.assign("ilp_graph_color")
 
     arch = Architecture(
         processing_elements,
@@ -333,7 +333,7 @@ def test_ilp_color_with_colors_given(mem_variables_fft16):
     for i, mem in enumerate(mem_vars_set):
         memory = Memory(mem, memory_type="RAM", entity_name=f"memory{i}")
         memories.append(memory)
-        memory.assign("graph_color")
+        memory.assign("ilp_graph_color")
 
     arch = Architecture(
         processing_elements,
@@ -360,7 +360,7 @@ def test_ilp_color_input_mux(mem_variables_fft16):
     for i, mem in enumerate(mem_vars_set):
         memory = Memory(mem, memory_type="RAM", entity_name=f"memory{i}")
         memories.append(memory)
-        memory.assign("graph_color")
+        memory.assign("ilp_graph_color")
 
     arch = Architecture(
         processing_elements,
@@ -387,7 +387,7 @@ def test_ilp_color_output_mux(mem_variables_fft16):
     for i, mem in enumerate(mem_vars_set):
         memory = Memory(mem, memory_type="RAM", entity_name=f"memory{i}")
         memories.append(memory)
-        memory.assign("graph_color")
+        memory.assign("ilp_graph_color")
 
     arch = Architecture(
         processing_elements,
@@ -415,7 +415,7 @@ def test_ilp_color_total_mux(mem_variables_fft16):
     for i, mem in enumerate(mem_vars_set):
         memory = Memory(mem, memory_type="RAM", entity_name=f"memory{i}")
         memories.append(memory)
-        memory.assign("graph_color")
+        memory.assign("ilp_graph_color")
 
     arch = Architecture(
         processing_elements,
@@ -480,7 +480,7 @@ def test_ilp_resource_algorithm_custom_solver():
     for i, mem in enumerate(mem_vars_set):
         memory = Memory(mem, memory_type="RAM", entity_name=f"memory{i}")
         memories.append(memory)
-        memory.assign("graph_color")
+        memory.assign("ilp_graph_color")
 
     arch = Architecture(
         processing_elements,
diff --git a/test/unit/test_list_schedulers.py b/test/unit/test_list_schedulers.py
index e36a3152..8a0a5f01 100644
--- a/test/unit/test_list_schedulers.py
+++ b/test/unit/test_list_schedulers.py
@@ -1857,7 +1857,7 @@ class TestListScheduler:
         for i, mem in enumerate(mem_vars_set):
             memory = Memory(mem, memory_type="RAM", entity_name=f"memory{i}")
             memories.append(memory)
-            memory.assign("graph_color")
+            memory.assign("greedy_graph_color")
 
         arch = Architecture(
             processing_elements,
@@ -1939,7 +1939,7 @@ class TestListScheduler:
         for i, mem in enumerate(mem_vars_set):
             memory = Memory(mem, memory_type="RAM", entity_name=f"memory{i}")
             memories.append(memory)
-            memory.assign("graph_color")
+            memory.assign("greedy_graph_color")
 
         arch = Architecture(
             processing_elements,
diff --git a/test/unit/test_resources.py b/test/unit/test_resources.py
index e7a93ba9..e64f4cea 100644
--- a/test/unit/test_resources.py
+++ b/test/unit/test_resources.py
@@ -160,7 +160,7 @@ class TestProcessCollectionPlainMemoryVariable:
         collection = generate_matrix_transposer(4, min_lifetime=5)
         assignment_left_edge = collection._left_edge_assignment()
         assignment_graph_color = collection.split_on_execution_time(
-            strategy="graph_color", coloring_strategy="saturation_largest_first"
+            strategy="greedy_graph_color", coloring_strategy="saturation_largest_first"
         )
         assert len(assignment_left_edge) == 18
         assert len(assignment_graph_color) == 16
@@ -182,7 +182,9 @@ class TestProcessCollectionPlainMemoryVariable:
             collection = generate_matrix_transposer(
                 rows=rows, cols=cols, min_lifetime=0
             )
-            assignment = collection.split_on_execution_time(strategy="graph_color")
+            assignment = collection.split_on_execution_time(
+                strategy="greedy_graph_color"
+            )
             collection.generate_memory_based_storage_vhdl(
                 filename=(
                     "b_asic/codegen/testbench/"
@@ -334,12 +336,12 @@ class TestProcessCollectionPlainMemoryVariable:
         assert exclusion_graph.degree(p2) == 1
         assert exclusion_graph.degree(p3) == 3
 
-    def test_left_edge_maximum_lifetime(self):
+    def test_split_on_execution_time_maximum_lifetime(self):
         a = PlainMemoryVariable(2, 0, {0: 1}, "cmul1.0")
         b = PlainMemoryVariable(4, 0, {0: 7}, "cmul4.0")
         c = PlainMemoryVariable(5, 0, {0: 4}, "cmul5.0")
         collection = ProcessCollection([a, b, c], schedule_time=7, cyclic=True)
-        for strategy in ("graph_color", "left_edge"):
+        for strategy in ("greedy_graph_color", "left_edge", "ilp_graph_color"):
             assignment = collection.split_on_execution_time(strategy)
             assert len(assignment) == 2
             a_idx = 0 if a in assignment[0] else 1
@@ -349,7 +351,7 @@ class TestProcessCollectionPlainMemoryVariable:
     def test_split_on_execution_lifetime_assert(self):
         a = PlainMemoryVariable(3, 0, {0: 10}, "MV0")
         collection = ProcessCollection([a], schedule_time=9, cyclic=True)
-        for strategy in ("graph_color", "left_edge"):
+        for strategy in ("greedy_graph_color", "left_edge", "ilp_graph_color"):
             with pytest.raises(
                 ValueError,
                 match="MV0 has execution time greater than the schedule time",
-- 
GitLab