-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdcp_optimizer.py
More file actions
executable file
·4714 lines (4183 loc) · 239 KB
/
Copy pathdcp_optimizer.py
File metadata and controls
executable file
·4714 lines (4183 loc) · 239 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
# Copyright (C) 2026, Advanced Micro Devices, Inc. All rights reserved.
# Portions of this file consist of AI-generated content.
# SPDX-License-Identifier: Apache 2.0
"""
FPGA Design Optimization Agent
An autonomous AI agent that analyzes FPGA designs and applies optimizations
using RapidWright and Vivado via MCP servers.
"""
import argparse
import asyncio
import json
import logging
import os
import re
import shutil
import sys
import tempfile
import threading
import time
from contextlib import AsyncExitStack
from pathlib import Path
from typing import Optional
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from openai import OpenAI
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
handlers=[logging.StreamHandler(sys.stderr)]
)
logger = logging.getLogger(__name__)
# Default model
DEFAULT_MODEL = "google/gemini-3.1-flash-lite"
def parse_timing_summary_static(timing_report: str) -> dict:
"""
Parse timing summary report to extract WNS, TNS, and failing endpoints.
Returns dict with keys: wns, tns, failing_endpoints
Parses the Design Timing Summary table:
WNS(ns) TNS(ns) TNS Failing Endpoints ...
------- ------- --------------------- ...
-0.099 -1.449 42 ...
This is a shared utility function used by both FPGAOptimizer and FPGAOptimizerTest.
"""
result = {
"wns": None,
"tns": None,
"failing_endpoints": None
}
lines = timing_report.split('\n')
# Find the line with "WNS(ns)" header
header_idx = -1
for i, line in enumerate(lines):
if 'WNS(ns)' in line and 'TNS(ns)' in line:
header_idx = i
break
if header_idx == -1:
return result
# The data line should be 2 lines after the header (skipping the dashes line)
# Format: whitespace + values separated by whitespace
data_idx = header_idx + 2
if data_idx >= len(lines):
return result
data_line = lines[data_idx].strip()
if not data_line:
return result
# Split by whitespace and extract first 3 values: WNS, TNS, TNS Failing Endpoints
parts = data_line.split()
if len(parts) >= 3:
try:
result["wns"] = float(parts[0])
result["tns"] = float(parts[1])
result["failing_endpoints"] = int(parts[2])
except (ValueError, IndexError):
# If parsing fails, leave as None
pass
return result
def load_system_prompt() -> str:
"""Load system prompt from SYSTEM_PROMPT.TXT file."""
script_dir = Path(__file__).parent.resolve()
prompt_file = script_dir / "SYSTEM_PROMPT.TXT"
try:
with open(prompt_file, 'r') as f:
return f.read()
except FileNotFoundError:
logger.error(f"System prompt file not found: {prompt_file}")
raise
except Exception as e:
logger.error(f"Failed to load system prompt: {e}")
raise
def load_planner_prompt() -> Optional[str]:
"""Load the LLM-planner prompt from PLANNER_PROMPT.TXT. Returns None if missing
(the caller then falls back to the default deterministic recipe)."""
prompt_file = Path(__file__).parent.resolve() / "PLANNER_PROMPT.TXT"
try:
with open(prompt_file, 'r') as f:
return f.read()
except Exception as e:
logger.warning(f"Planner prompt not loadable ({e}); planner will fall back to default recipe.")
return None
def convert_mcp_tool_to_openai(tool, server_prefix: str) -> dict:
"""Convert MCP tool definition to OpenAI-compatible format with server prefix."""
schema = tool.inputSchema or {"type": "object", "properties": {}}
return {
"type": "function",
"function": {
"name": f"{server_prefix}_{tool.name}",
"description": tool.description or "",
"parameters": {
"type": "object",
"properties": schema.get("properties", {}),
"required": schema.get("required", [])
}
}
}
class DCPOptimizerBase:
"""Base class with shared functionality for FPGA optimization."""
def __init__(self, debug: bool = False, run_dir: Optional[Path] = None):
self.debug = debug
# Create run directory if not provided
if run_dir is None:
timestamp = time.strftime("%Y%m%d_%H%M%S")
self.run_dir = Path.cwd() / f"dcp_optimizer_run-{timestamp}"
self.run_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Created run directory: {self.run_dir}")
else:
self.run_dir = run_dir
self.run_dir.mkdir(parents=True, exist_ok=True)
self.exit_stack = AsyncExitStack()
self.rapidwright_session: Optional[ClientSession] = None
self.vivado_session: Optional[ClientSession] = None
# Use run directory for all temporary files
self.temp_dir = self.run_dir
logger.info(f"Working directory: {self.temp_dir}")
# Timing tracking
self.initial_wns = None
self.initial_tns = None
self.initial_failing_endpoints = None
self.high_fanout_nets = []
self.clock_period = None
self.target_clock = None # Set to clock name (e.g. "clk_fpl26contest") for clock-specific Fmax
# Critical-path spread (drives whether a pblock re-placement is worthwhile)
self.critical_path_spread_info = None
self.pblock_recommended = False
# Log file handles
self._rw_log_file = None
self._v_log_file = None
async def start_servers(self, log_prefix: str = ""):
"""Start and connect to both MCP servers."""
script_dir = Path(__file__).parent.resolve()
# Create log files in run directory
rapidwright_log = self.run_dir / "rapidwright.log"
rapidwright_mcp_log = self.run_dir / "rapidwright-mcp.log"
vivado_log = self.run_dir / "vivado.log"
vivado_journal = self.run_dir / "vivado.jou"
vivado_mcp_log = self.run_dir / "vivado-mcp.log"
# Open log files (if not in debug mode, redirect stderr to log)
if self.debug:
self._rw_log_file = None
self._v_log_file = None
logger.info("Debug mode: MCP server output will be shown in console")
if log_prefix:
print(f"{log_prefix} Debug mode: MCP server output will be shown in console")
else:
self._rw_log_file = open(rapidwright_mcp_log, 'w')
self._v_log_file = open(vivado_mcp_log, 'w')
logger.info(f"RapidWright Java output: {rapidwright_log}")
logger.info(f"RapidWright MCP output: {rapidwright_mcp_log}")
logger.info(f"Vivado output: {vivado_log}")
logger.info(f"Vivado journal: {vivado_journal}")
logger.info(f"Vivado MCP output: {vivado_mcp_log}")
print(f"Log files in {self.run_dir.name}/: {rapidwright_log.name}, {rapidwright_mcp_log.name}, {vivado_log.name}, {vivado_journal.name}, {vivado_mcp_log.name}")
# RapidWright MCP server config
rapidwright_args = [str(script_dir / "RapidWrightMCP" / "server.py")]
if not self.debug:
rapidwright_args.extend([
"--java-log", str(rapidwright_log),
"--mcp-log", str(rapidwright_mcp_log)
])
env = {**os.environ}
rapidwright_submodule = script_dir / "RapidWright"
if rapidwright_submodule.is_dir() and "RAPIDWRIGHT_PATH" not in env:
env["RAPIDWRIGHT_PATH"] = str(rapidwright_submodule)
env["CLASSPATH"] = f"{rapidwright_submodule}/bin:{rapidwright_submodule}/jars/*"
rapidwright_config = {
"command": sys.executable,
"args": rapidwright_args,
"cwd": str(self.run_dir),
"env": env
}
# Vivado MCP server config
vivado_args = [str(script_dir / "VivadoMCP" / "vivado_mcp_server.py")]
if not self.debug:
vivado_args.extend([
"--vivado-log", str(vivado_log),
"--vivado-journal", str(vivado_journal)
])
vivado_config = {
"command": sys.executable,
"args": vivado_args,
"cwd": str(self.run_dir),
"env": {**os.environ}
}
# Start RapidWright MCP
logger.info("Starting RapidWright MCP server...")
if log_prefix:
print(f"{log_prefix} Starting RapidWright MCP server...")
start_time = time.time()
rw_params = StdioServerParameters(**rapidwright_config)
rw_transport = await self.exit_stack.enter_async_context(
stdio_client(rw_params, errlog=self._rw_log_file)
)
rw_read, rw_write = rw_transport
self.rapidwright_session = await self.exit_stack.enter_async_context(
ClientSession(rw_read, rw_write)
)
await self.rapidwright_session.initialize()
elapsed = time.time() - start_time
logger.info(f"RapidWright MCP server started in {elapsed:.2f}s")
if log_prefix:
print(f"{log_prefix} RapidWright MCP server started in {elapsed:.2f}s")
# Start Vivado MCP
logger.info("Starting Vivado MCP server...")
if log_prefix:
print(f"{log_prefix} Starting Vivado MCP server...")
start_time = time.time()
vivado_params = StdioServerParameters(**vivado_config)
vivado_transport = await self.exit_stack.enter_async_context(
stdio_client(vivado_params, errlog=self._v_log_file)
)
v_read, v_write = vivado_transport
self.vivado_session = await self.exit_stack.enter_async_context(
ClientSession(v_read, v_write)
)
await self.vivado_session.initialize()
elapsed = time.time() - start_time
logger.info(f"Vivado MCP server started in {elapsed:.2f}s")
if log_prefix:
print(f"{log_prefix} Vivado MCP server started in {elapsed:.2f}s")
logger.info("Both MCP servers connected")
if log_prefix:
print(f"{log_prefix} Both MCP servers connected successfully")
async def cleanup(self):
"""Clean up resources."""
await self.exit_stack.aclose()
if self._rw_log_file:
self._rw_log_file.close()
if self._v_log_file:
self._v_log_file.close()
logger.info(f"Run directory preserved at: {self.run_dir}")
def calculate_fmax(self, wns: Optional[float], clock_period: Optional[float]) -> Optional[float]:
"""
Calculate achievable fmax in MHz based on WNS and clock period.
fmax = 1 / (clock_period - WNS) when WNS < 0 (timing violation)
fmax = 1 / clock_period when WNS >= 0 (timing met)
Returns fmax in MHz, or None if cannot be calculated.
"""
if clock_period is None or clock_period <= 0:
return None
if wns is None:
return None
achievable_period_ns = clock_period - wns
if achievable_period_ns <= 0:
return None
return 1000.0 / achievable_period_ns
async def get_clock_period(self, call_tool_fn) -> Optional[float]:
"""
Query the clock period of the target clock from Vivado in nanoseconds.
First checks for the contest clock 'clk_fpl26contest'. If found, uses its
period and sets self.target_clock. Otherwise falls back to the endpoint clock
of the worst setup timing path.
Args:
call_tool_fn: Function to call Vivado tools, should accept (tool_name, arguments)
Returns the period of the target clock, or None if no clocks found.
"""
tcl_cmd = (
"set contest_clk [get_clocks -quiet clk_fpl26contest]; "
"if {$contest_clk ne {}} { "
" puts \"CLOCK:clk_fpl26contest\"; "
" puts [get_property PERIOD $contest_clk]; "
"} else { "
" set tp [get_timing_paths -max_paths 1 -setup]; "
" if {$tp ne {}} { "
" set clk [get_property ENDPOINT_CLOCK $tp]; "
" if {$clk ne {}} { "
" puts \"CLOCK:$clk\"; "
" puts [get_property PERIOD [get_clocks $clk]]; "
" } "
" } "
"}"
)
try:
result = await call_tool_fn("run_tcl", {"command": tcl_cmd})
clock_name = None
for token in result.strip().split():
if token.startswith('CLOCK:'):
clock_name = token[len('CLOCK:'):]
continue
if token.startswith('ERROR') or token.startswith('WARNING'):
continue
try:
period = float(token)
if period > 0:
if clock_name:
self.target_clock = clock_name
logger.info(f"Target clock: {clock_name}, period: {period:.3f} ns")
else:
logger.info(f"Critical clock period: {period:.3f} ns")
return period
except ValueError:
continue
except Exception as e:
logger.warning(f"Failed to get clock period: {e}")
logger.warning("Could not determine clock period from Vivado")
return None
async def get_wns_for_target_clock(self, call_tool_fn) -> Optional[float]:
"""
Get WNS specifically for the target clock domain.
When target_clock is set (e.g. 'clk_fpl26contest'), queries WNS filtered
to that clock's timing paths. Falls back to overall WNS if no target clock.
Args:
call_tool_fn: Function to call Vivado tools, should accept (tool_name, arguments)
Returns WNS in nanoseconds, or None if query fails.
"""
if self.target_clock:
tcl_cmd = (
f"set clk_obj [get_clocks -quiet {{{self.target_clock}}}]; "
f"if {{$clk_obj ne {{}}}} {{ "
f" set tp [get_timing_paths -max_paths 1 -setup -to $clk_obj]; "
f" if {{[llength $tp] > 0}} {{get_property SLACK $tp}} else {{puts 0.0}} "
f"}} else {{ "
f" set tp [get_timing_paths -max_paths 1 -slack_lesser_than 999]; "
f" if {{[llength $tp] > 0}} {{get_property SLACK $tp}} else {{puts 0.0}} "
f"}}"
)
else:
tcl_cmd = (
"set tp [get_timing_paths -max_paths 1 -slack_lesser_than 999]; "
"if {[llength $tp] > 0} {get_property SLACK $tp} else {puts 0.0}"
)
try:
result = await call_tool_fn("run_tcl", {"command": tcl_cmd})
for token in result.strip().split('\n'):
token = token.strip()
if not token or token.startswith('ERROR') or token.startswith('WARNING'):
continue
try:
wns = float(token)
clock_info = f" (clock: {self.target_clock})" if self.target_clock else ""
logger.info(f"WNS{clock_info}: {wns:.3f} ns")
return wns
except ValueError:
continue
except Exception as e:
logger.warning(f"Failed to get WNS for target clock: {e}")
return None
def parse_high_fanout_nets(self, report: str) -> list[tuple[str, int, int]]:
"""
Parse high fanout nets report and return list of (net_name, fanout, path_count).
"""
nets = []
lines = report.split('\n')
in_net_section = False
for line in lines:
if 'Paths' in line and 'Fanout' in line and 'Parent Net Name' in line:
in_net_section = True
continue
if in_net_section:
if line.startswith('---') or not line.strip():
continue
if line.startswith('==='):
break
parts = line.split()
if len(parts) >= 3:
try:
path_count = int(parts[0])
fanout = int(parts[1])
net_name = parts[2]
if (net_name and
'/' in net_name and
not net_name.startswith('get_') and
not net_name.startswith('ERROR') and
not net_name.startswith('WARNING')):
nets.append((net_name, fanout, path_count))
except ValueError:
continue
return nets
def _format_fmax_results(
self,
clock_period: Optional[float],
initial_wns: Optional[float],
result_wns: Optional[float],
result_label: str = "Final",
) -> list[str]:
"""Format Fmax/WNS results block as a list of lines.
"""
initial_fmax = self.calculate_fmax(initial_wns, clock_period)
result_fmax = self.calculate_fmax(result_wns, clock_period)
result_fmax_label = f"{result_label} Fmax:"
result_wns_label = f"{result_label} WNS:"
lines: list[str] = []
if initial_fmax is not None and result_fmax is not None:
target_fmax = 1000.0 / clock_period
fmax_change = result_fmax - initial_fmax
lines.append(f" {'Target Fmax:':<21s}{target_fmax:8.2f} MHz (clock period: {clock_period:.3f} ns)")
lines.append(f" {'Initial Fmax:':<21s}{initial_fmax:8.2f} MHz (WNS: {initial_wns:.3f} ns)")
lines.append(f" {result_fmax_label:<21s}{result_fmax:8.2f} MHz (WNS: {result_wns:.3f} ns)")
lines.append(f" {'Fmax Improvement:':<21s}{fmax_change:+8.2f} MHz (WNS: {result_wns - initial_wns:+.3f} ns)")
else:
if clock_period is not None:
target_fmax = 1000.0 / clock_period
lines.append(f" {'Clock period:':<21s}{clock_period:8.3f} ns (target: {target_fmax:.2f} MHz)")
if initial_wns is not None:
fmax_str = f" (fmax: {initial_fmax:.2f} MHz)" if initial_fmax else ""
lines.append(f" {'Initial WNS:':<21s}{initial_wns:8.3f} ns{fmax_str}")
if result_wns is not None:
fmax_str = f" (fmax: {result_fmax:.2f} MHz)" if result_fmax else ""
lines.append(f" {result_wns_label:<21s}{result_wns:8.3f} ns{fmax_str}")
if initial_wns is not None and result_wns is not None:
lines.append(f" {'WNS Improvement:':<21s}{result_wns - initial_wns:+8.3f} ns")
return lines
def print_wns_change(
self,
initial_wns: Optional[float],
final_wns: Optional[float],
clock_period: Optional[float]
):
"""Print Fmax/WNS change comparison with improvement/regression status."""
if final_wns is None or initial_wns is None:
return
initial_fmax = self.calculate_fmax(initial_wns, clock_period)
final_fmax = self.calculate_fmax(final_wns, clock_period)
if initial_fmax is not None and final_fmax is not None:
fmax_improvement = final_fmax - initial_fmax
pct = (fmax_improvement / initial_fmax) * 100 if initial_fmax else 0
print(f"\n*** Fmax: {initial_fmax:.2f} -> {final_fmax:.2f} MHz ({fmax_improvement:+.2f} MHz, {pct:+.1f}%) ***")
print(f"*** WNS: {initial_wns:.3f} -> {final_wns:.3f} ns ***")
if fmax_improvement > 0:
print(f"IMPROVEMENT: Fmax improved by {fmax_improvement:.2f} MHz")
elif fmax_improvement < 0:
print(f"REGRESSION: Fmax got worse by {-fmax_improvement:.2f} MHz")
else:
print("NO CHANGE: Fmax is the same")
else:
wns_improvement = final_wns - initial_wns
print(f"\n*** WNS: {initial_wns:.3f} -> {final_wns:.3f} ns ({wns_improvement:+.3f} ns) ***")
if wns_improvement > 0:
print(f"IMPROVEMENT: WNS improved by {wns_improvement:.3f} ns")
elif wns_improvement < 0:
print(f"REGRESSION: WNS got worse by {-wns_improvement:.3f} ns")
else:
print("NO CHANGE")
def print_fmax_status(self, label: str, wns: Optional[float]):
"""Print Fmax (primary) and WNS (secondary) for a given measurement point."""
if wns is None:
print(f"*** {label}: WNS unknown ***")
return
fmax = self.calculate_fmax(wns, self.clock_period)
clock_info = f" (clock: {self.target_clock})" if self.target_clock else ""
if fmax is not None:
print(f"*** {label} Fmax{clock_info}: {fmax:.2f} MHz (WNS: {wns:.3f} ns) ***")
else:
print(f"*** {label} WNS{clock_info}: {wns:.3f} ns ***")
def print_test_summary(
self,
title: str,
elapsed_seconds: float,
initial_wns: Optional[float],
final_wns: Optional[float],
clock_period: Optional[float],
extra_info: str = ""
):
"""Print formatted test summary."""
print("\n" + "="*70)
print(title)
print("="*70)
print(f"Total runtime: {elapsed_seconds:.2f} seconds ({elapsed_seconds/60:.2f} minutes)")
result_lines = self._format_fmax_results(clock_period, initial_wns, final_wns)
if result_lines:
print(f"\nFmax Results:")
print("\n".join(result_lines))
if extra_info:
print(f"\n{extra_info}")
print("="*70)
class DCPOptimizer(DCPOptimizerBase):
"""FPGA Design Optimization Agent using RapidWright and Vivado MCPs."""
def __init__(
self,
api_key: str,
model: str = DEFAULT_MODEL,
debug: bool = False,
run_dir: Optional[Path] = None,
pre_opt: str = "phys_opt,pblock,relocate,pinopt,reimpl,routeopt",
physopt_directive: str = "",
pblock_mode: str = "always",
cell_replace_mode: str = "auto",
relocate_mode: str = "always",
retime_mode: str = "always",
reimpl_mode: str = "always",
reimpl_place_directive: str = "ExtraTimingOpt",
reimpl_route_directive: str = "AggressiveExplore",
reimpl_skip_if_recovered: float = 0.30,
skip_llm: bool = False,
use_planner: bool = True,
phys_opt_timeout: int = 1200,
manual_timeout: int = 1200,
reimpl_timeout: int = 2400,
llm_timeout: int = 1200,
total_timeout: int = 3600,
hard_deadline: int = 3420,
cost_cap: float = 1.0
):
super().__init__(debug=debug, run_dir=run_dir)
self.api_key = api_key
self.model = model
# Deterministic pre-LLM optimization pipeline, run in order before the LLM.
# e.g. "phys_opt,pblock" -> phys_opt baseline, then pblock re-placement.
self.pre_opt_steps = [s.strip() for s in (pre_opt or "").split(",")
if s.strip() and s.strip() != "none"]
self.physopt_directive = physopt_directive # optional phys_opt directive
self.pblock_mode = pblock_mode # auto | always | never
self.cell_replace_mode = cell_replace_mode # auto | always | never
self.relocate_mode = relocate_mode # auto | always | never
self.retime_mode = retime_mode # auto | always | never
self.reimpl_mode = reimpl_mode # auto | always | never (final fallback stage)
self.reimpl_place_directive = reimpl_place_directive # place_design directive for re-impl
self.reimpl_route_directive = reimpl_route_directive # route_design directive for re-impl
# Skip re-impl once the incremental stages have recovered this fraction of the
# initial slack deficit (re-impl only wins from a weak incremental result).
self.reimpl_skip_if_recovered = reimpl_skip_if_recovered
self.skip_llm = skip_llm # stop after deterministic baseline
# LLM PLANNER (front): one up-front call reads the design diagnosis and picks the
# ordered recipe of OPTIONAL stages that run AFTER the mandatory phys_opt. Advisory
# only -- any failure falls back to the full default recipe, so the floor is never
# worse than the proven deterministic flow. Disabled by --skip-llm or --no-planner.
self.use_planner = use_planner
self._planner_ran = False
# Wall-clock budgets (seconds) and cost cap ($). Contest limit: 1 hr + $1/benchmark.
# Phased 20/20/20: phys_opt | manual (pblock + cell_replace SHARE this) | LLM.
self.phys_opt_timeout = phys_opt_timeout # phase-1 cap for phys_opt
self.manual_timeout = manual_timeout # phase-2 cap SHARED by pblock + cell_replace
self.reimpl_timeout = reimpl_timeout # dedicated cap for the re-impl fallback stage
self.llm_timeout = llm_timeout # phase-3 cap for the LLM stage
self.total_timeout = total_timeout # soft cap: gates STAGE STARTS only
# HARD wall-clock kill. The stage budgets above are SOFT -- a running Vivado
# place/route cannot be interrupted (the MCP server waits up to 3600s for it),
# so on huge designs (e.g. boom) a stage entered just under total_timeout can
# overrun to 2.5h. This watchdog GUARANTEES we stop under the contest's 1-hour
# limit: at hard_deadline it force-writes the protected-best DCP as the scored
# output and kills the process. Because the protected best is saved (and
# equivalence-checked) after every successful stage, the output is always valid.
self.hard_deadline = hard_deadline
self._watchdog: Optional[threading.Timer] = None
self.cost_cap = cost_cap # stop LLM before spending more than this
# Output-DCP protection: the contest scores the most-recently-modified
# *_optimized*.dcp, so we keep a protected copy of the best design and
# guarantee the final written output is never worse than it.
self.output_dcp: Optional[Path] = None
self._golden_input: Optional[Path] = None # original input DCP, for equivalence gating
self.protected_best_dcp: Optional[Path] = None
self.protected_best_wns = float('-inf')
# Set True only when a stage EDITS the netlist (cell_replace's RapidWright
# round-trip, retime, or the LLM). Placement/routing-only stages (phys_opt,
# pblock, relocate, pinopt, reimpl) leave the netlist untouched and are provably
# equivalent, so finalize skips the expensive simulation check when this stays False.
self._netlist_touched = False
self.tools: list[dict] = []
self.messages: list[dict] = []
# LLM client is only needed when the LLM stage runs; skip when baseline-only.
self.openai = OpenAI(
api_key=api_key,
base_url="https://openrouter.ai/api/v1"
) if api_key else None
# Track optimization progress
self.iteration = 0
self.best_wns = float('-inf')
self.no_improvement_count = 0
self.llm_call_count = 0
# Track token usage and costs
self.total_prompt_tokens = 0
self.total_completion_tokens = 0
self.total_tokens = 0
self.total_cost = 0.0
self.api_call_details = []
# Track all tool calls with timing and WNS
self.tool_call_details = []
# Track total runtime
self.start_time = None
self.end_time = None
async def start_servers(self):
"""Start and connect to both MCP servers."""
await super().start_servers()
await self._collect_tools()
logger.info(f"Connected to servers with {len(self.tools)} tools available")
async def _collect_tools(self):
"""Collect and convert tools from both MCP servers."""
self.tools = []
rw_response = await self.rapidwright_session.list_tools()
for tool in rw_response.tools:
self.tools.append(convert_mcp_tool_to_openai(tool, "rapidwright"))
v_response = await self.vivado_session.list_tools()
for tool in v_response.tools:
self.tools.append(convert_mcp_tool_to_openai(tool, "vivado"))
async def call_tool(self, tool_name: str, arguments: dict) -> str:
"""Execute a tool call on the appropriate MCP server."""
# Parse server prefix from tool name
if tool_name.startswith("rapidwright_"):
session = self.rapidwright_session
actual_name = tool_name[len("rapidwright_"):]
elif tool_name.startswith("vivado_"):
session = self.vivado_session
actual_name = tool_name[len("vivado_"):]
else:
return json.dumps({"error": f"Unknown tool prefix in: {tool_name}"})
# Track timing for this tool call
start_time = time.time()
wns_measured = None
error_occurred = False
try:
logger.info(f"Calling {tool_name} with args: {json.dumps(arguments)[:200]}...")
result = await session.call_tool(actual_name, arguments)
# Extract text content from result
if result.content:
text_parts = [c.text for c in result.content if hasattr(c, 'text')]
result_text = "\n".join(text_parts)
else:
result_text = "(no output)"
# Track WNS from timing reports and get_wns calls
if tool_name == "vivado_report_timing_summary":
# If target clock is set, get clock-specific WNS instead of overall
if self.target_clock:
try:
clock_wns = await super().get_wns_for_target_clock(self._call_vivado_tool)
if clock_wns is not None:
current_wns = clock_wns
wns_measured = current_wns
current_fmax = self.calculate_fmax(current_wns, self.clock_period)
fmax_str = f", fmax: {current_fmax:.2f} MHz" if current_fmax is not None else ""
if current_wns > self.best_wns:
logger.info(f"New best WNS (clock: {self.target_clock}): {current_wns:.3f} ns{fmax_str} (improved from {self.best_wns:.3f} ns)")
self.best_wns = current_wns
else:
logger.info(f"Current WNS (clock: {self.target_clock}): {current_wns:.3f} ns{fmax_str} (best is still {self.best_wns:.3f} ns)")
except Exception as e:
logger.warning(f"Failed to get clock-specific WNS, falling back to overall: {e}")
self.target_clock = None # Fall through to overall WNS parsing
if not self.target_clock or wns_measured is None:
timing_info = parse_timing_summary_static(result_text)
if timing_info["wns"] is not None:
current_wns = timing_info["wns"]
wns_measured = current_wns
current_fmax = self.calculate_fmax(current_wns, self.clock_period)
fmax_str = f", fmax: {current_fmax:.2f} MHz" if current_fmax is not None else ""
if current_wns > self.best_wns:
logger.info(f"New best WNS: {current_wns:.3f} ns{fmax_str} (improved from {self.best_wns:.3f} ns)")
self.best_wns = current_wns
else:
logger.info(f"Current WNS: {current_wns:.3f} ns{fmax_str} (best is still {self.best_wns:.3f} ns)")
# Also track WNS from get_wns tool (returns just the numeric WNS value)
elif tool_name == "vivado_get_wns":
try:
current_wns = float(result_text.strip())
wns_measured = current_wns
current_fmax = self.calculate_fmax(current_wns, self.clock_period)
fmax_str = f", fmax: {current_fmax:.2f} MHz" if current_fmax is not None else ""
if current_wns > self.best_wns:
logger.info(f"New best WNS (from get_wns): {current_wns:.3f} ns{fmax_str} (improved from {self.best_wns:.3f} ns)")
self.best_wns = current_wns
else:
logger.info(f"Current WNS (from get_wns): {current_wns:.3f} ns{fmax_str} (best is still {self.best_wns:.3f} ns)")
except (ValueError, AttributeError):
logger.warning(f"Could not parse WNS from get_wns output: {result_text[:100]}")
elapsed_time = time.time() - start_time
# Record tool call details
self.tool_call_details.append({
"tool_name": tool_name,
"iteration": self.iteration,
"elapsed_time": elapsed_time,
"wns": wns_measured,
"error": False
})
return result_text
except Exception as e:
error_occurred = True
elapsed_time = time.time() - start_time
# Record failed tool call
self.tool_call_details.append({
"tool_name": tool_name,
"iteration": self.iteration,
"elapsed_time": elapsed_time,
"wns": None,
"error": True,
"error_message": str(e)
})
logger.error(f"Tool call failed: {e}")
return json.dumps({"error": str(e)})
async def _call_vivado_tool(self, tool_name: str, arguments: dict) -> str:
"""Helper to call Vivado tools (for use with base class methods)."""
return await self.call_tool(f"vivado_{tool_name}", arguments)
async def process_response(self, response) -> tuple[str, bool]:
"""Process LLM response, execute tool calls, return final text and done flag."""
# Validate response structure with detailed logging
try:
if not response:
raise ValueError("Response is None")
if not hasattr(response, 'choices'):
raise ValueError(f"Response has no 'choices' attribute. Response type: {type(response)}, Response: {response}")
if response.choices is None:
raise ValueError("Response.choices is None")
if len(response.choices) == 0:
raise ValueError("Response choices list is empty")
message = response.choices[0].message
if not message:
raise ValueError("Message is None")
except Exception as e:
logger.error(f"Failed to parse response structure: {e}")
logger.error(f"Response object: {response}")
raise
# Convert message to dict, excluding None values which can cause issues
message_dict = message.model_dump(exclude_none=True)
self.messages.append(message_dict)
if self.debug:
logger.debug(f"Added message to conversation: {json.dumps(message_dict, indent=2)[:500]}...")
# Check for tool calls
if message.tool_calls:
tool_results = []
for tool_call in message.tool_calls:
# Validate tool_call structure
if not tool_call or not hasattr(tool_call, 'function') or not tool_call.function:
logger.warning(f"Invalid tool_call structure: {tool_call}")
continue
tool_name = tool_call.function.name
try:
tool_args = json.loads(tool_call.function.arguments) if tool_call.function.arguments else {}
except json.JSONDecodeError:
tool_args = {}
result = await self.call_tool(tool_name, tool_args)
# Truncate very long results to avoid API issues
MAX_RESULT_LENGTH = 50000 # characters
if len(result) > MAX_RESULT_LENGTH:
logger.warning(f"Tool result from {tool_name} is {len(result)} chars, truncating to {MAX_RESULT_LENGTH}")
result = result[:MAX_RESULT_LENGTH] + f"\n...[truncated {len(result) - MAX_RESULT_LENGTH} characters]"
tool_results.append({
"role": "tool",
"tool_call_id": tool_call.id,
"name": tool_name,
"content": result
})
# Debug logging
if self.debug:
logger.debug(f"Tool {tool_name} result: {result[:500]}...")
# Add tool results to messages
self.messages.extend(tool_results)
# Continue conversation
return await self.get_completion()
# No tool calls - check if we're done
content = message.content or ""
# Check for completion indicators
is_done = any(phrase in content.lower() for phrase in [
"optimization complete",
"timing is met",
"wns >= 0",
"no more optimizations",
"design meets timing",
"successfully saved",
"final design saved"
])
return content, is_done
async def perform_initial_analysis(self, input_dcp: Path) -> str:
"""
Perform initial analysis without LLM:
1. Initialize RapidWright
2. Open checkpoint in Vivado
3. Report timing summary
4. Get critical high fanout nets
Returns a formatted summary of the analysis.
"""
logger.info("Performing initial design analysis...")
print("\n=== Initial Design Analysis ===\n")
# Step 1: Initialize RapidWright
logger.info("Initializing RapidWright...")
print("Initializing RapidWright...")
result = await self.call_tool("rapidwright_initialize_rapidwright", {})
if "error" in result.lower() and "success" not in result.lower():
raise RuntimeError(f"Failed to initialize RapidWright: {result}")
print("✓ RapidWright initialized\n")
# Step 2: Open checkpoint in Vivado
logger.info(f"Opening checkpoint: {input_dcp}")
print(f"Opening checkpoint: {input_dcp.name}")
result = await self.call_tool("vivado_open_checkpoint", {
"dcp_path": str(input_dcp.resolve())
})
if "error" in result.lower() and "opened successfully" not in result.lower():
raise RuntimeError(f"Failed to open checkpoint: {result}")
print("✓ Checkpoint opened in Vivado\n")
# Step 3: Report timing summary
logger.info("Analyzing timing...")
print("Analyzing timing...")
timing_report = await self.call_tool("vivado_report_timing_summary", {})
# Parse timing
timing_info = parse_timing_summary_static(timing_report)
self.initial_tns = timing_info["tns"]
self.initial_failing_endpoints = timing_info["failing_endpoints"]
# Get clock period for fmax calculation (also detects target clock)
self.clock_period = await super().get_clock_period(self._call_vivado_tool)
# Get WNS for the target clock domain
target_wns = await super().get_wns_for_target_clock(self._call_vivado_tool)
if target_wns is not None:
self.initial_wns = target_wns
else:
self.initial_wns = timing_info["wns"]
self.best_wns = self.initial_wns if self.initial_wns is not None else float('-inf')
clock_info = f" (clock: {self.target_clock})" if self.target_clock else ""
print(f"✓ Timing analyzed:")
if self.clock_period is not None:
target_fmax = 1000.0 / self.clock_period
print(f" - Clock period: {self.clock_period:.3f} ns (target fmax: {target_fmax:.2f} MHz)")
if self.target_clock:
print(f" - Target clock: {self.target_clock}")
if self.initial_wns is not None:
print(f" - WNS{clock_info}: {self.initial_wns:.3f} ns")
initial_fmax = self.calculate_fmax(self.initial_wns, self.clock_period)
if initial_fmax is not None:
print(f" - Achievable fmax: {initial_fmax:.2f} MHz")
if self.initial_tns is not None:
print(f" - TNS: {self.initial_tns:.3f} ns")
if self.initial_failing_endpoints is not None:
print(f" - Failing endpoints: {self.initial_failing_endpoints}")
print()
# Step 4: Get critical high fanout nets
logger.info("Identifying critical high fanout nets...")
print("Identifying critical high fanout nets...")
nets_report = await self.call_tool("vivado_get_critical_high_fanout_nets", {
"num_paths": 50,
"min_fanout": 100
})
# Parse high fanout nets
self.high_fanout_nets = self.parse_high_fanout_nets(nets_report)
print(f"✓ Found {len(self.high_fanout_nets)} high fanout nets (>100 fanout)\n")
# Step 5: Load design in RapidWright for spread analysis
critical_path_spread_info = None # Initialize
logger.info("Loading design in RapidWright...")
print("Loading design in RapidWright for spread analysis...")
result = await self.call_tool("rapidwright_read_checkpoint", {
"dcp_path": str(input_dcp.resolve())
})
if "error" in result.lower() and "success" not in result.lower():
print(f"⚠ Warning: Could not load design in RapidWright: {result}")
else:
print("✓ Design loaded in RapidWright\n")
# Step 6: Extract critical path cells and analyze spread
logger.info("Extracting and analyzing critical path spread...")
print("Analyzing critical path spread...")
# Extract critical path cells from Vivado
temp_path = Path(self.temp_dir) / "initial_critical_paths.json"
cells_json = await self.call_tool("vivado_extract_critical_path_cells", {