├── .gitignore ├── LICENSE.txt ├── README.md ├── assets ├── active.ico └── default.ico ├── build.bat ├── docs ├── rapterlake.png └── zen3.jpg ├── requirements.txt ├── saturn_affinity ├── __init__.py ├── __main__.py ├── cache_lib.py ├── gui.py └── saturn_affinity_lib.py ├── setup.cfg └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | dist/* 2 | build/* 3 | venv/* 4 | .idea/* 5 | game_list.txt 6 | *.spec 7 | *.egg-info 8 | __pycache__ 9 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 SaturnSky 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Saturn Affinity 2 | 3 | Program to optimize cache utilization for games on Ryzen CPUs with multiple L3 cache clusters. 4 | 5 | The purpose of this program is to optimize the speed of cache-sensitive games, even when background programs are running. 6 | 7 | Additionally, it supports Intel CPUs with both P and E cores. In this case, the only effect is to prevent background programs from reducing the performance of the game. 8 | 9 | ## Download 10 | * [Saturn Affinity Release Link](https://github.com/saturnsky/saturn_affinity_python/releases) 11 | 12 | ## How it works (AMD CPU) 13 | 1. If a program in the Game List is in the foreground, it is assigned to the cores with access to the largest L3 cache, and all other programs are assigned to the remaining cores. 14 | 2. Otherwise, reassign them so that all programs have access to all cores. 15 | 16 | ## How it works (Intel CPU) 17 | 1. If a program in the game list is in the foreground, it is assigned to P-cores and all other programs are assigned to E-cores. 18 | 2. Otherwise, reassign them so that all programs have access to all cores. 19 | 20 | ## What are the effects of this program? (AMD CPU) 21 | ![AMD Ryzen 5000 Series Diagram](./docs/zen3.jpg) 22 | Zen 3 and Zen 4 have one L3 cache per CCD, and there is no benefit to having L3 caches on different CCDs. 23 | 24 | This program allocates the game being played to CCD0 and all other programs to CCD1, which has the effect of making the L3 cache on CCD0 exclusively used by the game. 25 | 26 | CPUs with more L3 cache capacity for CCD0, such as the Ryzen 9 7950X3D and Ryzen 9 7900X3D, will see a greater effect, but in my testing, for cache-sensitive games, the Ryzen 9 5950X also saw a significant effect. 27 | 28 | Older generation Ryzen CPUs (such as the Ryzen 7 2700X), which have two CCXs on a single CCD and cannot benefit from caches from different CCXs, are expected to see a similar effect. However, the smaller the L3 cache capacity, the less effective it will be. 29 | 30 | For games that are highly cache-sensitive but weak on multithreading, CPUs with two CCDs and each CCD has two CCXs (such as the Ryzen 9 3950X) may also see performance gains, but many games are expected to experience performance drops due to the quartering of the number of cores allocated. 31 | 32 | ## What are the effects of this program? (Intel CPU) 33 | ![Rapter Lake E-Cores Slide](./docs/rapterlake.png) 34 | Modern Intel CPUs are organized into P-cores for high-performance tasks and E-cores for multi-threaded performance. 35 | 36 | This program allocates the game being played to P-cores and all other programs to E-cores, which has the effect of making the P-cores exclusively used by the game. 37 | 38 | This will prevent other programs from using P-cores to interfere with the game's scheduling, so expect to see performance improvements when using this program. 39 | 40 | ## Are there any games that shouldn't use this program? 41 | For games that spawn threads based on the number of cores in your CPU, it's possible that performance will suffer. This tool should not be used in such games. 42 | 43 | Even if this is not the case, games that can utilize a large number of threads may experience a performance drop on CPUs with fewer cores. 44 | 45 | ## Caution 46 | Theoretically, this program extends the functionality to work on modern Intel CPUs, but it has not been tested in an actual Intel CPU environment. Therefore, I cannot guarantee its behavior in this case. 47 | 48 | ## Compatibility 49 | Theoretically, this program should work on CPUs with multiple L3 cache clusters. It is assumed that the larger the L3 cache cluster, the better the effect. 50 | 51 | The program can also be used on CPUs with both P and E cores. In this case, the program will automatically recognize the cores that support hyperthreading as P-cores and the remaining cores as E-cores. 52 | 53 | ### Tested CPU 54 | - Ryzen 9 5950X (The game exclusively utilizes 8 cores and 32MB of L3 cache) 55 | - Ryzen 9 7950X3D (The game exclusively utilizes 8 cores and 96MB of L3 cache) 56 | 57 | ## Benchmark 58 | 59 | ### Stellaris 60 | 61 | #### Benchmark Settings 62 | - Galaxy Size: Huge (1000) 63 | - Galaxy Shape: Spiral (2 Arms) 64 | - AI Empires: 15 65 | - Advanced AI Stars: 4 66 | - Fallen Empires: 4 67 | - Marauder Empires: 3 68 | 69 | #### Benchmark Methods 1 70 | 1. Start the game with the above settings. 71 | 2. Save the game. 72 | 3. All tests started by loading the game above. 73 | 4. Use the human_ai console command. 74 | 5. See how many in-game days pass for 5 minutes at fastest speed. 75 | 6. Repeat steps 3 to 5 3 times. 76 | 77 | ##### Benchmark System 1 78 | Ryzen 9 5950X, DDR4-3200 128GB, Windows 11 [10.0.22621.1344] 79 | 80 | This system is not a clean environment because the operating system has been installed for a very long time, and there are many background programs running, so the effect may be exaggerated compared to a typical PC. 81 | 82 | - 3112 days/ 3118 days/ 3113 days -> 4696 days/ 4662 days/ 4695 days 83 | 84 | 50.4% performance improvement. 85 | 86 | ##### Benchmark System 2 87 | Ryzen 9 7950X3D, DDR5-4800 128GB, Windows 11 [10.0.22621.1344] 88 | 89 | This system is not a clean environment because the operating system has been installed for a very long time, and there are many background programs running, so the effect may be exaggerated compared to a typical PC. 90 | 91 | - 5568 days/ 5547 days/ 5526 days -> 6755 days/ 6708 days/ 6681 days 92 | 93 | 21.1% performance improvement. 94 | 95 | #### Benchmark Methods 2 96 | 1. Spend time until the year 2400. 97 | 2. Save the game. 98 | 3. All tests started by loading the game above. 99 | 4. Use the human_ai console command. 100 | 5. See how many in-game days pass for 5 minutes at fastest speed. 101 | 6. Repeat steps 3 to 5 3 times. 102 | 103 | 104 | ##### Benchmark System 2 105 | Ryzen 9 7950X3D, DDR5-4800 128GB, Windows 11 [10.0.22621.1344] 106 | 107 | This system is not a clean environment because the operating system has been installed for a very long time, and there are many background programs running, so the effect may be exaggerated compared to a typical PC. 108 | 109 | - 1474 days/ 1492 days/ 1461 days -> 1836 days/ 1861 days/ 1737 days 110 | 111 | 22.7% performance improvement. -------------------------------------------------------------------------------- /assets/active.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saturnsky/saturn_affinity_python/9d1bfcfc43b6b0c332d566646e4c272aa5a48411/assets/active.ico -------------------------------------------------------------------------------- /assets/default.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saturnsky/saturn_affinity_python/9d1bfcfc43b6b0c332d566646e4c272aa5a48411/assets/default.ico -------------------------------------------------------------------------------- /build.bat: -------------------------------------------------------------------------------- 1 | pyinstaller --onefile --noconsole saturn_affinity/__main__.py --icon "./assets/default.ico" --name "SaturnAffinity" --add-data "assets/*.ico;assets" -------------------------------------------------------------------------------- /docs/rapterlake.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saturnsky/saturn_affinity_python/9d1bfcfc43b6b0c332d566646e4c272aa5a48411/docs/rapterlake.png -------------------------------------------------------------------------------- /docs/zen3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saturnsky/saturn_affinity_python/9d1bfcfc43b6b0c332d566646e4c272aa5a48411/docs/zen3.jpg -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyinstaller==5.7.0 2 | pywin32==305 3 | pywin32-ctypes==0.2.0 4 | pystray==0.19.4 -------------------------------------------------------------------------------- /saturn_affinity/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saturnsky/saturn_affinity_python/9d1bfcfc43b6b0c332d566646e4c272aa5a48411/saturn_affinity/__init__.py -------------------------------------------------------------------------------- /saturn_affinity/__main__.py: -------------------------------------------------------------------------------- 1 | from saturn_affinity import gui 2 | 3 | gui.main() 4 | -------------------------------------------------------------------------------- /saturn_affinity/cache_lib.py: -------------------------------------------------------------------------------- 1 | # This code is based on https://stackoverflow.com/questions/66027768/how-to-invoke-getlogicalprocessorinformation-in-python. 2 | # Author: https://stackoverflow.com/users/235698/mark-tolonen 3 | 4 | 5 | from ctypes import * 6 | from ctypes import wintypes as w 7 | 8 | ULONG_PTR = c_size_t # c_ulong on Win32, c_ulonglong on Win64. 9 | ULONGLONG = c_ulonglong 10 | 11 | ERROR_INSUFFICIENT_BUFFER = 122 12 | 13 | 14 | class CACHE_DESCRIPTOR(Structure): 15 | _fields_ = ( 16 | ("Level", w.BYTE), 17 | ("Associativity", w.BYTE), 18 | ("LineSize", w.WORD), 19 | ("Size", w.DWORD), 20 | ("Type", c_int), 21 | ) 22 | 23 | 24 | class ProcessorCore(Structure): 25 | _fields_ = (("Flags", w.BYTE),) 26 | 27 | 28 | class NumaNode(Structure): 29 | _fields_ = (("NodeNumber", w.DWORD),) 30 | 31 | 32 | class DUMMYUNIONNAME(Union): 33 | _fields_ = ( 34 | ("ProcessorCore", ProcessorCore), 35 | ("NumaNode", NumaNode), 36 | ("Cache", CACHE_DESCRIPTOR), 37 | ("Reserved", ULONGLONG * 2), 38 | ) 39 | 40 | 41 | class SYSTEM_LOGICAL_PROCESSOR_INFORMATION(Structure): 42 | _anonymous_ = ("DUMMYUNIONNAME",) 43 | _fields_ = ( 44 | ("ProcessorMask", ULONG_PTR), 45 | ("Relationship", c_int), 46 | ("DUMMYUNIONNAME", DUMMYUNIONNAME), 47 | ) 48 | 49 | 50 | dll = WinDLL("kernel32", use_last_error=True) 51 | dll.GetLogicalProcessorInformation.argtypes = ( 52 | POINTER(SYSTEM_LOGICAL_PROCESSOR_INFORMATION), 53 | w.LPDWORD, 54 | ) 55 | dll.GetLogicalProcessorInformation.restype = w.BOOL 56 | 57 | 58 | # wrapper for easier use 59 | def GetLogicalProcessorInformation(): 60 | bytelength = w.DWORD() 61 | structlength = sizeof(SYSTEM_LOGICAL_PROCESSOR_INFORMATION) 62 | # Call with null buffer to get required buffer size 63 | result = dll.GetLogicalProcessorInformation(None, byref(bytelength)) 64 | if (err := get_last_error()) != ERROR_INSUFFICIENT_BUFFER: 65 | raise WinError(err) 66 | no_of_structures = bytelength.value // structlength 67 | arr = (SYSTEM_LOGICAL_PROCESSOR_INFORMATION * no_of_structures)() 68 | result = dll.GetLogicalProcessorInformation(arr, byref(bytelength)) 69 | if not result: 70 | raise WinError(get_last_error()) 71 | return arr 72 | -------------------------------------------------------------------------------- /saturn_affinity/gui.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import time 4 | import tkinter as tk 5 | 6 | import ctypes 7 | import pystray 8 | import win32api 9 | import win32event 10 | import win32process 11 | import winerror 12 | from PIL import Image 13 | 14 | import json 15 | 16 | from saturn_affinity import saturn_affinity_lib as sal 17 | 18 | 19 | class App(tk.Frame): 20 | def tray_setup(self): 21 | tray_menu = ( 22 | pystray.MenuItem("Show", self.on_showing), 23 | pystray.MenuItem("Quit", self.on_closing), 24 | ) 25 | self.icon = pystray.Icon( 26 | name="Saturn Affinity", 27 | icon=self.default_icon, 28 | title="Saturn Affinity", 29 | menu=tray_menu, 30 | ) 31 | self.icon.run_detached() 32 | 33 | def __init__(self, master=None): 34 | super().__init__(master) 35 | self.master = master 36 | 37 | self.default_icon = Image.open(self.resource_path("assets/default.ico")) 38 | self.active_icon = Image.open(self.resource_path("assets/active.ico")) 39 | self.icon = None 40 | 41 | self.master.iconbitmap(self.resource_path("assets/default.ico")) 42 | 43 | self.tray_setup() 44 | 45 | self.master.title("Saturn Affinity") 46 | self.master.geometry("640x720") 47 | self.master.resizable(True, True) 48 | self.master.protocol("WM_DELETE_WINDOW", self.hide_tray) 49 | 50 | self.processes_label = tk.Label(self.master, text="Processes") 51 | self.processes_label.grid(row=0, column=0) 52 | 53 | self.process_listbox = tk.Listbox(self.master, activestyle="none") 54 | self.process_listbox.grid(row=1, column=0, sticky="nsew") 55 | self.process_listbox.bind("<>", self.on_listbox_select) 56 | 57 | self.game_label = tk.Label(self.master, text="Game") 58 | self.game_label.grid(row=0, column=1) 59 | 60 | self.game_listbox = tk.Listbox(self.master, activestyle="none") 61 | self.game_listbox.grid(row=1, column=1, sticky="nsew") 62 | self.game_listbox.bind("<>", self.on_listbox_select) 63 | 64 | self.btn_frame = tk.Frame(self.master) 65 | self.btn_frame.grid(row=2, column=0, sticky="nsew", columnspan=2) 66 | 67 | self.refresh_btn = tk.Button( 68 | self.btn_frame, text="Refresh", command=self.processes_update 69 | ) 70 | self.refresh_btn.pack(side="left", expand=True, fill="x") 71 | 72 | self.delete_btn = tk.Button( 73 | self.btn_frame, text="Delete", command=self.delete_game_item 74 | ) 75 | self.delete_btn.pack(side="right", expand=True, fill="x") 76 | self.delete_btn.config(state=tk.DISABLED) 77 | 78 | self.config_frame = tk.Frame(self.master) 79 | self.config_frame.grid(row=3, column=0, sticky="nsew", columnspan=2) 80 | 81 | self.affinity_select_frame = tk.Frame(self.config_frame) 82 | self.affinity_select_frame.grid(row=0, column=1, sticky="nsew") 83 | 84 | self.priority_select_frame = tk.Frame(self.config_frame) 85 | self.priority_select_frame.grid(row=1, column=1, sticky="nsew") 86 | 87 | self.config_btn_frame = tk.Frame(self.config_frame) 88 | self.config_btn_frame.grid(row=0, column=2, sticky="nsew", rowspan=2) 89 | 90 | self.config_frame.grid_columnconfigure(0, weight=1) 91 | self.config_frame.grid_columnconfigure(1, weight=2) 92 | self.config_frame.grid_columnconfigure(2, weight=0, minsize=60) 93 | 94 | # AMD Multi-CCX 95 | 96 | self.core_checkbox_list = [] 97 | self.core_checkbox_value = [] 98 | 99 | self.cluster_label = tk.Label( 100 | self.config_frame, 101 | text="Core clusters to assign the application to:", 102 | ) 103 | self.cluster_label.grid(row=0, column=0, sticky="nsew") 104 | 105 | names = sal.get_names_of_core_clusters() 106 | if sal.get_total_core_cluster_count() == 1: 107 | self.core_checkbox_list.append( 108 | tk.Checkbutton( 109 | self.affinity_select_frame, 110 | text=names[0], 111 | ) 112 | ) 113 | self.core_checkbox_list[-1].pack(side="left", expand=True, fill="x") 114 | self.core_checkbox_list[-1].toggle() 115 | self.core_checkbox_list[-1].config(state=tk.DISABLED) 116 | else: 117 | for idx, name in enumerate(names): 118 | self.core_checkbox_value.append(tk.IntVar()) 119 | self.core_checkbox_list.append( 120 | tk.Checkbutton( 121 | self.affinity_select_frame, 122 | text=name, 123 | variable=self.core_checkbox_value[-1], 124 | ) 125 | ) 126 | self.core_checkbox_list[-1].pack(side="left", expand=True, fill="x") 127 | self.update_core_checkbox() 128 | 129 | self.priority_label = tk.Label(self.config_frame, text="Priority:") 130 | self.priority_label.grid(row=1, column=0, sticky="nsew") 131 | 132 | self.priority_radio_list = [] 133 | self.priority_radio_value = tk.IntVar() 134 | self.priority_radio_value.set(win32process.NORMAL_PRIORITY_CLASS) 135 | self.priority_radio_list.append( 136 | tk.Radiobutton( 137 | self.priority_select_frame, 138 | text="Normal", 139 | variable=self.priority_radio_value, 140 | value=win32process.NORMAL_PRIORITY_CLASS, 141 | ) 142 | ) 143 | self.priority_radio_list[-1].pack(side="left", expand=True, fill="x") 144 | self.priority_radio_list.append( 145 | tk.Radiobutton( 146 | self.priority_select_frame, 147 | text="High", 148 | variable=self.priority_radio_value, 149 | value=win32process.HIGH_PRIORITY_CLASS, 150 | ) 151 | ) 152 | self.priority_radio_list[-1].pack(side="left", expand=True, fill="x") 153 | 154 | self.add_btn = tk.Button( 155 | self.config_btn_frame, text="Add", command=self.add_game_item 156 | ) 157 | self.add_btn.pack(side="right", expand=True, fill=tk.BOTH) 158 | self.add_btn.config(state=tk.DISABLED) 159 | 160 | self.action_frame = tk.Frame(self.master) 161 | self.action_frame.grid(row=4, column=0, sticky="nsew", columnspan=2) 162 | 163 | self.action_label_disable_text = ( 164 | "Normal Mode. The process affinity setting has been turned off." 165 | ) 166 | self.action_label_error_text = "Error. This CPU is not supported" 167 | 168 | self.action_label = tk.Label( 169 | self.action_frame, text=self.action_label_disable_text 170 | ) 171 | self.action_label.pack(side="left", expand=True, fill="both") 172 | 173 | self.master.grid_rowconfigure(1, weight=1) 174 | self.master.grid_columnconfigure(0, weight=1) 175 | self.master.grid_columnconfigure(1, weight=1) 176 | self.master.grid_rowconfigure(4, minsize=40) 177 | self.current_windows = [] 178 | self.game_list = [] 179 | self.game_set = set() 180 | self.current_game = None 181 | self.previous_update = 0 182 | 183 | self.processes_update() 184 | self.load_game_list() 185 | if sal.get_current_cpu_type() == "Normal": 186 | self.action_label.config(text=self.action_label_error_text) 187 | else: 188 | self.periodic_update() 189 | 190 | def update_core_checkbox(self, cluster_mask=None): 191 | if len(self.core_checkbox_list) == 1: 192 | return 193 | for idx, core_checkbox in enumerate(self.core_checkbox_list): 194 | if cluster_mask: 195 | if cluster_mask & (1 << idx): 196 | self.core_checkbox_value[idx].set(1) 197 | else: 198 | self.core_checkbox_value[idx].set(0) 199 | else: # Default 200 | if idx < len(self.core_checkbox_list) // 2: 201 | self.core_checkbox_value[idx].set(1) 202 | else: 203 | self.core_checkbox_value[idx].set(0) 204 | 205 | def get_cluster_mask(self): 206 | if len(self.core_checkbox_list) == 1: 207 | return 1 208 | 209 | cluster_mask = 0 210 | for idx, core_checkbox in enumerate(self.core_checkbox_list): 211 | if self.core_checkbox_value[idx].get(): 212 | cluster_mask |= 1 << idx 213 | return cluster_mask 214 | 215 | def update_priority_radio(self, priority): 216 | self.priority_radio_value.set(priority) 217 | 218 | def on_listbox_select(self, event): 219 | selected_listbox = event.widget 220 | selected_indices = selected_listbox.curselection() 221 | if len(selected_indices) == 0: 222 | return 223 | 224 | if event.widget == self.game_listbox: 225 | self.delete_btn.config(state=tk.NORMAL) 226 | self.add_btn.config(state=tk.NORMAL) 227 | self.add_btn.config(text="Update") 228 | else: 229 | self.delete_btn.config(state=tk.DISABLED) 230 | self.add_btn.config(state=tk.NORMAL) 231 | self.add_btn.config(text="Add") 232 | 233 | if selected_listbox == self.game_listbox: 234 | self.process_listbox.selection_clear(0, tk.END) 235 | self.update_core_checkbox( 236 | self.game_list[selected_indices[0]].get("cluster_mask") 237 | ) 238 | self.update_priority_radio( 239 | self.game_list[selected_indices[0]].get( 240 | "priority_level", win32process.NORMAL_PRIORITY_CLASS 241 | ) 242 | ) 243 | elif selected_listbox == self.process_listbox: 244 | self.update_core_checkbox(1) 245 | self.update_priority_radio(win32process.NORMAL_PRIORITY_CLASS) 246 | self.game_listbox.selection_clear(0, tk.END) 247 | 248 | def processes_update(self): 249 | self.current_windows = sal.get_windows_info() 250 | self.process_listbox.delete(0, tk.END) 251 | for idx, window in enumerate(self.current_windows): 252 | self.process_listbox.insert( 253 | idx, "{} ({})".format(window[2], window[1].split("\\")[-1]) 254 | ) 255 | 256 | def load_legacy_game_list(self, raw_data): 257 | for line in raw_data: 258 | program_path, program_name = line.split("|", 1) 259 | self.game_list.append( 260 | { 261 | "name": program_name, 262 | "path": program_path, 263 | } 264 | ) 265 | self.game_listbox.insert( 266 | tk.END, "{} ({})".format(program_name, program_path.split("\\")[-1]) 267 | ) 268 | self.game_set.add(program_path) 269 | 270 | def load_game_list(self): 271 | self.game_list = [] 272 | self.game_set = set() 273 | if os.path.exists("game_list.txt"): 274 | self.game_listbox.delete(0, tk.END) 275 | with open("game_list.txt", "r", encoding="utf-8") as f: 276 | raw_data = f.read().splitlines() 277 | if not len(raw_data): 278 | return 279 | if "|" in raw_data[0]: # legacy support 280 | self.load_legacy_game_list(raw_data) 281 | return 282 | # The first line contains the version information for the save file. 283 | for line in raw_data[1:]: 284 | game_info = json.loads(line) 285 | self.game_list.append(game_info) 286 | self.game_listbox.insert( 287 | tk.END, 288 | "{} ({})".format( 289 | game_info["name"], game_info["path"].split("\\")[-1] 290 | ), 291 | ) 292 | self.game_set.add(game_info["path"]) 293 | return 294 | 295 | def save_game_list(self): 296 | with open("game_list.txt", "w", encoding="utf-8") as f: 297 | f.write("1.0\n") 298 | for game in self.game_list: 299 | f.write(json.dumps(game) + "\n") 300 | 301 | def update_game_item(self): 302 | selected_game = self.game_listbox.curselection()[0] 303 | cluster_mask = self.get_cluster_mask() 304 | self.game_list[selected_game]["cluster_mask"] = cluster_mask 305 | self.game_list[selected_game][ 306 | "priority_level" 307 | ] = self.priority_radio_value.get() 308 | self.save_game_list() 309 | 310 | def add_game_item(self): 311 | if not self.process_listbox.curselection(): 312 | if self.game_listbox.curselection(): 313 | self.update_game_item() 314 | return 315 | selected_window = self.current_windows[self.process_listbox.curselection()[0]] 316 | if selected_window[1] in self.game_set: 317 | return 318 | self.game_listbox.insert( 319 | tk.END, 320 | "{} ({})".format(selected_window[2], selected_window[1].split("\\")[-1]), 321 | ) 322 | cluster_mask = self.get_cluster_mask() 323 | self.game_list.append( 324 | { 325 | "name": selected_window[2], 326 | "path": selected_window[1], 327 | "cluster_mask": cluster_mask, 328 | "priority_level": self.priority_radio_value.get(), 329 | } 330 | ) 331 | self.save_game_list() 332 | self.game_set.add(selected_window[1]) 333 | 334 | def delete_game_item(self): 335 | if not self.game_listbox.curselection(): 336 | return 337 | selected_game = self.game_listbox.curselection()[0] 338 | self.game_set.remove(self.game_list[selected_game]["path"]) 339 | self.game_listbox.delete(selected_game) 340 | del self.game_list[selected_game] 341 | self.save_game_list() 342 | self.delete_btn.config(state=tk.DISABLED) 343 | 344 | def active_action(self, current_process): 345 | if self.current_game != current_process[1]: 346 | if sal.get_current_cpu_type() == "AMD_MultiCCX": 347 | self.action_label.config( 348 | text="Number of exclusive threads: {} Exclusive L3 cache size: {}MB\n" 349 | "Enable the process affinity setting for {}.".format( 350 | sal.get_thread_count_in_core_cluster(0), 351 | sal.get_cache_size_in_core_cluster(0, "MB"), 352 | current_process[2], 353 | ) 354 | ) 355 | elif sal.get_current_cpu_type() == "Intel_BigLittle": 356 | self.action_label.config( 357 | text="Number of exclusive threads: {} (P-cores Only)\n" 358 | "Enable the process affinity setting for {}.".format( 359 | sal.get_thread_count_in_core_cluster(0), current_process[2] 360 | ) 361 | ) 362 | self.icon.icon = self.active_icon 363 | self.master.iconbitmap(self.resource_path("assets/active.ico")) 364 | 365 | self.current_game = current_process[1] 366 | for game in self.game_list: 367 | if game["path"] == current_process[1]: 368 | sal.set_process_affinity_and_priority( 369 | current_process[1], 370 | cluster_mask=game["cluster_mask"], 371 | priority_level=game["priority_level"], 372 | ) 373 | break 374 | 375 | self.previous_update = time.time() 376 | 377 | def inactive_action(self): 378 | self.icon.icon = self.default_icon 379 | self.master.iconbitmap(self.resource_path("assets/default.ico")) 380 | self.current_game = None 381 | sal.set_process_affinity_and_priority() 382 | self.action_label.config(text=self.action_label_disable_text) 383 | 384 | def periodic_update(self): 385 | current_process = sal.get_foreground_process_info() 386 | 387 | if current_process is None: 388 | self.after(1000, self.periodic_update) 389 | return 390 | if current_process[1] in self.game_set: 391 | if ( 392 | self.current_game != current_process[1] 393 | or time.time() - self.previous_update > 60 * 5 394 | ): 395 | self.active_action(current_process) 396 | else: 397 | if self.current_game: 398 | self.inactive_action() 399 | 400 | self.after(1000, self.periodic_update) 401 | 402 | def on_closing(self): 403 | sal.set_process_affinity_and_priority() 404 | self.icon.stop() 405 | self.quit() 406 | 407 | @staticmethod 408 | def resource_path(relative_path): 409 | if hasattr(sys, "_MEIPASS"): 410 | return os.path.join(sys._MEIPASS, relative_path) 411 | return os.path.join(os.path.abspath("."), relative_path) 412 | 413 | def on_showing(self): 414 | self.processes_update() 415 | self.master.after(0, self.master.deiconify) 416 | 417 | def hide_tray(self): 418 | self.master.withdraw() 419 | 420 | 421 | def main(): 422 | mutex = win32event.CreateMutex(None, 1, "SaturnAffinity") 423 | last_error = win32api.GetLastError() 424 | if last_error == winerror.ERROR_ALREADY_EXISTS: 425 | mutex = None 426 | ctypes.windll.user32.MessageBoxW( 427 | 0, "Saturn Affinity is already running.", "Saturn Affinity", 0 428 | ) 429 | sys.exit(0) 430 | 431 | app = App(master=tk.Tk()) 432 | app.mainloop() 433 | -------------------------------------------------------------------------------- /saturn_affinity/saturn_affinity_lib.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import win32process 4 | import win32api 5 | import win32con 6 | import win32gui 7 | 8 | from saturn_affinity import cache_lib 9 | 10 | processed_process = None 11 | processed_time = 0 12 | application_only_mode = False 13 | target_applications = set() 14 | 15 | current_cpu_type = None 16 | core_clusters = [] 17 | all_cluster_mask = 0 18 | 19 | best_cluster_thread_count = 0 20 | 21 | priority_updated_p_name = None 22 | 23 | 24 | def get_processor_count(): 25 | sysinfo = win32api.GetSystemInfo() 26 | return sysinfo[5] 27 | 28 | 29 | def get_process_info_from_window_handle(hwnd): 30 | try: 31 | pid = win32process.GetWindowThreadProcessId(hwnd) 32 | handle = win32api.OpenProcess(win32con.MAXIMUM_ALLOWED, win32con.FALSE, pid[1]) 33 | p_name = win32process.GetModuleFileNameEx(handle, 0) 34 | win32api.CloseHandle(handle) 35 | return pid, p_name 36 | except Exception as e: 37 | return None 38 | 39 | 40 | def get_foreground_process_info(): 41 | for retry in range(0, 3): 42 | try: 43 | hwnd = win32gui.GetForegroundWindow() 44 | process = get_process_info_from_window_handle(hwnd) 45 | text = win32gui.GetWindowText(hwnd) 46 | return process[0], process[1], text 47 | except Exception as e: 48 | continue 49 | return None 50 | 51 | 52 | def get_windows_info(visible_only=True): 53 | def callback(hwnd, hwnds): 54 | if win32gui.IsWindowVisible(hwnd) or not visible_only: 55 | try: 56 | process = get_process_info_from_window_handle(hwnd) 57 | text = win32gui.GetWindowText(hwnd) 58 | if text: 59 | hwnds.append((process[0], process[1], win32gui.GetWindowText(hwnd))) 60 | except Exception as e: 61 | pass 62 | return True 63 | 64 | hwnds = [] 65 | win32gui.EnumWindows(callback, hwnds) 66 | return hwnds 67 | 68 | 69 | def set_process_affinity_and_priority( 70 | target_pname=None, 71 | *, 72 | cluster_mask=0, 73 | priority_level=win32process.NORMAL_PRIORITY_CLASS 74 | ): 75 | """ 76 | Set process affinity mask and priority level for a specified target process or reset for all processes. 77 | 78 | This function sets the process affinity mask and priority level for a specified target process and 79 | sets the affinity mask of all other processes to the complement of the input cluster mask. If the target 80 | process name is not specified, the function resets the affinity mask and priority level for all processes 81 | to their original values. 82 | 83 | Args: 84 | target_pname (str, optional): The name of the target process. If None, the function will 85 | reset the affinity mask and priority level for all processes. Default is None. 86 | cluster_mask (int, optional): The bit mask for the cluster to which the target process 87 | should be assigned. If cluster_mask is 0 or all clusters, it will not be assigned 88 | to any specific cluster. Default is 0. 89 | priority_level (int, optional): The priority level for the target process. Default is 90 | win32process.NORMAL_PRIORITY_CLASS. 91 | 92 | Example usage: 93 | set_process_affinity_and_priority("target_process.exe", cluster_mask=0x3, 94 | priority_level=win32process.HIGH_PRIORITY_CLASS) 95 | """ 96 | global priority_updated_p_name 97 | affinity_cluster_mask = 0 98 | for cluster_idx, cluster in enumerate(core_clusters): 99 | if cluster_mask & (1 << cluster_idx): 100 | affinity_cluster_mask += cluster["ClusterMask"] 101 | otherwise_cluster_mask = all_cluster_mask - affinity_cluster_mask 102 | # print("Best Cluster Mask: %s" % hex(best_cluster_mask)) 103 | # print("Otherwise Cluster Mask: %s" % hex(otherwise_cluster_mask)) 104 | 105 | if affinity_cluster_mask == 0 or otherwise_cluster_mask == 0: 106 | affinity_cluster_mask = all_cluster_mask 107 | otherwise_cluster_mask = all_cluster_mask 108 | 109 | enum_processes = win32process.EnumProcesses() 110 | for pid in enum_processes: 111 | try: 112 | handle = win32api.OpenProcess(win32con.MAXIMUM_ALLOWED, win32con.FALSE, pid) 113 | p_name = win32process.GetModuleFileNameEx(handle, 0) 114 | if handle: 115 | if target_pname is not None: 116 | if p_name == target_pname: 117 | if ( 118 | win32process.GetProcessAffinityMask(handle)[0] 119 | != affinity_cluster_mask 120 | ): 121 | win32process.SetProcessAffinityMask( 122 | handle, affinity_cluster_mask 123 | ) 124 | print("Set affinity to best cluster for %s" % p_name) 125 | if win32process.GetPriorityClass(handle) != priority_level: 126 | win32process.SetPriorityClass(handle, priority_level) 127 | print( 128 | "Set priority to %s for %s" % (priority_level, p_name) 129 | ) 130 | priority_updated_p_name = p_name 131 | else: 132 | if ( 133 | win32process.GetProcessAffinityMask(handle)[0] 134 | != otherwise_cluster_mask 135 | ): 136 | win32process.SetProcessAffinityMask( 137 | handle, otherwise_cluster_mask 138 | ) 139 | else: 140 | if ( 141 | win32process.GetProcessAffinityMask(handle)[0] 142 | != all_cluster_mask 143 | ): 144 | win32process.SetProcessAffinityMask(handle, all_cluster_mask) 145 | if p_name == priority_updated_p_name: 146 | win32process.SetPriorityClass( 147 | handle, win32process.NORMAL_PRIORITY_CLASS 148 | ) 149 | win32api.CloseHandle(handle) 150 | except Exception as e: 151 | continue 152 | 153 | 154 | def get_processor_structure(): 155 | infos = cache_lib.GetLogicalProcessorInformation() 156 | cache_clusters = [] 157 | 158 | smt_mask = 0 159 | non_smt_mask = 0 160 | 161 | for info in infos: 162 | if info.Relationship == 2: # RelationCache 163 | if info.Cache.Level == 3: 164 | cache_clusters.append((info.ProcessorMask, info.Cache.Size)) 165 | elif info.Relationship == 0: # RelationProcessorCore 166 | if bin(info.ProcessorMask).count("1") > 1: 167 | smt_mask |= info.ProcessorMask 168 | else: 169 | non_smt_mask |= info.ProcessorMask 170 | 171 | cache_clusters = sorted(cache_clusters, key=lambda x: x[1], reverse=True) 172 | 173 | all_cluster_mask_local = 0 174 | 175 | core_clusters_local = [] 176 | 177 | for cluster in cache_clusters: 178 | all_cluster_mask_local |= cluster[0] 179 | 180 | # Multi Cache Cluster CPU (Supported AMD CPU) 181 | if len(cache_clusters) > 1: 182 | cpu_type = "AMD_MultiCCX" 183 | for idx, cluster in enumerate(cache_clusters): 184 | core_clusters_local.append( 185 | { 186 | "Name": "CCX %d" % (idx + 1), 187 | "ClusterMask": cluster[0], 188 | "ThreadCount": bin(cluster[0]).count("1"), 189 | "CacheSize": cluster[1], 190 | } 191 | ) 192 | elif smt_mask != all_cluster_mask_local: 193 | cpu_type = "Intel_BigLittle" 194 | core_clusters_local = [ 195 | { 196 | "Name": "P-Cores", 197 | "ClusterMask": smt_mask, 198 | "ThreadCount": bin(smt_mask).count("1"), 199 | "CacheSize": cache_clusters[0][1], 200 | }, 201 | { 202 | "Name": "E-Cores", 203 | "ClusterMask": non_smt_mask, 204 | "ThreadCount": bin(non_smt_mask).count("1"), 205 | "CacheSize": cache_clusters[0][1], 206 | }, 207 | ] 208 | else: 209 | cpu_type = "Normal" 210 | core_clusters_local = [ 211 | { 212 | "Name": "All", 213 | "ClusterMask": smt_mask, 214 | "ThreadCount": bin(smt_mask).count("1"), 215 | "CacheSize": cache_clusters[0][1], 216 | }, 217 | ] 218 | 219 | return core_clusters_local, all_cluster_mask_local, cpu_type 220 | 221 | 222 | def get_total_core_cluster_count(): 223 | return len(core_clusters) 224 | 225 | 226 | def get_names_of_core_clusters(): 227 | return [cluster["Name"] for cluster in core_clusters] 228 | 229 | 230 | # count of core in best cluster 231 | def get_thread_count_in_core_cluster(cluster_index): 232 | return core_clusters[cluster_index]["ThreadCount"] 233 | 234 | 235 | def get_cache_size_in_core_cluster(cluster_index, size_unit="MB"): 236 | cache_size = core_clusters[cluster_index]["CacheSize"] 237 | print(cache_size, core_clusters) 238 | if size_unit == "MB": 239 | return cache_size // 1048576 240 | elif size_unit == "KB": 241 | return cache_size // 1024 242 | else: 243 | return cache_size 244 | 245 | 246 | # Check supported CPU types 247 | def get_current_cpu_type(): 248 | return current_cpu_type 249 | 250 | 251 | core_clusters, all_cluster_mask, current_cpu_type = get_processor_structure() 252 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = saturn_affinity 3 | version = 0.5.1 4 | url = https://github.com/saturnsky/saturn_affinity_python 5 | author = SaturnSky 6 | author_email = ikadro@gmail.com 7 | license = The MIT License 8 | license_file = LICENSE.txt 9 | description = Expand standard functools to methods 10 | long_description = file: README.md 11 | classifier = 12 | License :: OSI Approved :: MIT License 13 | Programming Language :: Python :: 3 14 | Programming Language :: Python :: 3.10 15 | Programming Language :: Python :: 3.11 16 | [options] 17 | py_modules = saturn_affinity 18 | install_requires = 19 | pywin32==305 20 | pywin32-ctypes==0.2.0 21 | pystray==0.19.4 22 | [options.extras_require] 23 | installer = 24 | pyinstaller==5.7.0 25 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | assert tuple(map(int, setuptools.__version__.split(".")[:3])) >= ( 4 | 39, 5 | 2, 6 | 0, 7 | ), "Please upgrade setuptools by `pip install -U setuptools`" 8 | 9 | setuptools.setup() 10 | --------------------------------------------------------------------------------