sonolus.py 0.8.0__py3-none-any.whl → 0.9.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of sonolus.py might be problematic. Click here for more details.

sonolus/backend/ir.py CHANGED
@@ -5,12 +5,25 @@ type IRExpr = IRConst | IRPureInstr | IRGet
5
5
  type IRStmt = IRExpr | IRInstr | IRSet
6
6
 
7
7
 
8
+ _IR_CONST_CACHE_START = -5
9
+ _IR_CONST_CACHE_STOP = 257
10
+
11
+
8
12
  class IRConst:
13
+ __slots__ = ("value",)
14
+
9
15
  value: float | int
10
16
 
17
+ def __new__(cls, value):
18
+ if float(value).is_integer():
19
+ int_value = int(value)
20
+ if _IR_CONST_CACHE_START <= int_value < _IR_CONST_CACHE_STOP:
21
+ return _IR_CONST_CACHE[int_value - _IR_CONST_CACHE_START]
22
+ else:
23
+ return _create_raw_const(int_value)
24
+ return super().__new__(cls)
25
+
11
26
  def __init__(self, value: float):
12
- if isinstance(value, bool):
13
- value = int(value)
14
27
  self.value = value
15
28
 
16
29
  def __repr__(self):
@@ -26,7 +39,18 @@ class IRConst:
26
39
  return hash(self.value)
27
40
 
28
41
 
42
+ def _create_raw_const(value: float | int) -> IRConst:
43
+ result = object.__new__(IRConst)
44
+ result.value = value
45
+ return result
46
+
47
+
48
+ _IR_CONST_CACHE = tuple(_create_raw_const(i) for i in range(_IR_CONST_CACHE_START, _IR_CONST_CACHE_STOP))
49
+
50
+
29
51
  class IRPureInstr:
52
+ __slots__ = ("args", "array_defs", "defs", "is_array_init", "live", "op", "uses", "visited")
53
+
30
54
  op: Op
31
55
  args: list[IRExpr]
32
56
 
@@ -49,6 +73,8 @@ class IRPureInstr:
49
73
 
50
74
 
51
75
  class IRInstr:
76
+ __slots__ = ("args", "array_defs", "defs", "is_array_init", "live", "op", "uses", "visited")
77
+
52
78
  op: Op
53
79
  args: list[IRExpr]
54
80
 
@@ -70,6 +96,8 @@ class IRInstr:
70
96
 
71
97
 
72
98
  class IRGet:
99
+ __slots__ = ("array_defs", "defs", "is_array_init", "live", "place", "uses", "visited")
100
+
73
101
  place: Place
74
102
 
75
103
  def __init__(self, place: Place):
@@ -89,6 +117,8 @@ class IRGet:
89
117
 
90
118
 
91
119
  class IRSet:
120
+ __slots__ = ("array_defs", "defs", "is_array_init", "live", "place", "uses", "value", "visited")
121
+
92
122
  place: Place
93
123
  value: IRExpr | IRInstr
94
124
 
@@ -69,10 +69,14 @@ class CopyCoalesce(CompilerPass):
69
69
  def get_interference(self, entry: BasicBlock) -> dict[TempBlock, set[TempBlock]]:
70
70
  result = {}
71
71
  for block in traverse_cfg_preorder(entry):
72
- for stmt in [*block.statements, block.test]:
72
+ for stmt in block.statements:
73
73
  live = {p for p in get_live(stmt) if isinstance(p, TempBlock) and p.size == 1}
74
74
  for place in live:
75
75
  result.setdefault(place, set()).update(live - {place})
76
+ if not isinstance(block.test, IRConst):
77
+ live = {p for p in get_live(block.test) if isinstance(p, TempBlock) and p.size == 1}
78
+ for place in live:
79
+ result.setdefault(place, set()).update(live - {place})
76
80
  return result
77
81
 
78
82
  def get_copies(self, entry: BasicBlock) -> dict[TempBlock, set[TempBlock]]:
@@ -140,6 +140,20 @@ def cfg_to_mermaid(entry: BasicBlock):
140
140
  return f"graph\n{body}"
141
141
 
142
142
 
143
+ def hash_cfg(entry: BasicBlock) -> int:
144
+ # Note: not stable between Python runs due to hash randomization
145
+ block_indexes = {block: i for i, block in enumerate(traverse_cfg_preorder(entry))}
146
+
147
+ h = 0
148
+ for block, index in block_indexes.items():
149
+ outgoing = [(edge.cond, block_indexes.get(edge.dst, -1)) for edge in block.outgoing]
150
+ h = hash(
151
+ (h, index, tuple(block.statements), block.test, tuple(sorted(outgoing, key=lambda x: (x[0] is None, x[0]))))
152
+ )
153
+
154
+ return h
155
+
156
+
143
157
  def cfg_to_text(entry: BasicBlock) -> str:
144
158
  def indent(iterable, prefix=" "):
145
159
  for line in iterable:
@@ -50,7 +50,7 @@ class InlineVars(CompilerPass):
50
50
  # Update the definition if it's a Get from another SSAPlace until we reach a definition that is not a Get
51
51
  while defn and isinstance(defn, IRGet) and isinstance(defn.place, SSAPlace):
52
52
  canonical_definitions[p] = defn
53
- defn = definitions.get(defn.place, None) # Can be None if it's a phi
53
+ defn = definitions.get(defn.place) # Can be None if it's a phi
54
54
  canonical_defn = canonical_definitions[p]
55
55
  if (
56
56
  use_counts.get(p, 0) > 0
@@ -31,8 +31,9 @@ class LivenessAnalysis(CompilerPass):
31
31
  statement.defs = self.get_defs(statement)
32
32
  statement.is_array_init = False # True if this may be the first assignment to an array
33
33
  statement.array_defs = self.get_array_defs(statement)
34
- block.test.live = set()
35
- block.test.uses = self.get_uses(block.test, set())
34
+ if not isinstance(block.test, IRConst):
35
+ block.test.live = set()
36
+ block.test.uses = self.get_uses(block.test, set())
36
37
  self.preprocess_arrays(entry)
37
38
 
38
39
  def process(self, entry: BasicBlock):
@@ -72,8 +73,9 @@ class LivenessAnalysis(CompilerPass):
72
73
  for place in block.live_out
73
74
  if not (isinstance(place, TempBlock) and place.size > 1 and place not in block.array_defs_out)
74
75
  }
75
- block.test.live.update(live)
76
- live.update(block.test.uses)
76
+ if not isinstance(block.test, IRConst):
77
+ block.test.live.update(live)
78
+ live.update(block.test.uses)
77
79
  for statement in reversed(block.statements):
78
80
  statement.live.update(live)
79
81
  if self.can_skip(statement, live):
sonolus/build/cli.py CHANGED
@@ -1,17 +1,16 @@
1
1
  import argparse
2
- import contextlib
3
- import http.server
4
2
  import importlib
5
3
  import json
6
4
  import shutil
7
- import socket
8
- import socketserver
9
5
  import sys
10
6
  from pathlib import Path
11
7
  from time import perf_counter
8
+ from types import ModuleType
12
9
 
13
10
  from sonolus.backend.excepthook import print_simple_traceback
14
11
  from sonolus.backend.optimize.optimize import FAST_PASSES, MINIMAL_PASSES, STANDARD_PASSES
12
+ from sonolus.build.compile import CompileCache
13
+ from sonolus.build.dev_server import run_server
15
14
  from sonolus.build.engine import no_gil, package_engine, validate_engine
16
15
  from sonolus.build.level import package_level_data
17
16
  from sonolus.build.project import build_project_to_collection, get_project_schema
@@ -35,8 +34,10 @@ def find_default_module() -> str | None:
35
34
  return potential_modules[0] if len(potential_modules) == 1 else None
36
35
 
37
36
 
38
- def import_project(module_path: str) -> Project | None:
37
+ def import_project(module_path: str) -> tuple[Project, ModuleType, set[str]] | tuple[None, None, None]:
39
38
  try:
39
+ initial_modules = set(sys.modules)
40
+
40
41
  current_dir = Path.cwd()
41
42
  if current_dir not in sys.path:
42
43
  sys.path.insert(0, str(current_dir))
@@ -44,8 +45,8 @@ def import_project(module_path: str) -> Project | None:
44
45
  project = None
45
46
 
46
47
  try:
47
- module = importlib.import_module(module_path)
48
- project = getattr(module, "project", None)
48
+ project_module = importlib.import_module(module_path)
49
+ project = getattr(project_module, "project", None)
49
50
  except ImportError as e:
50
51
  if not str(e).endswith(f"'{module_path}'"):
51
52
  # It's an error from the module itself
@@ -61,9 +62,9 @@ def import_project(module_path: str) -> Project | None:
61
62
 
62
63
  if project is None:
63
64
  print(f"Error: No Project instance found in module {module_path} or {module_path}.project")
64
- return None
65
+ return None, None, None
65
66
 
66
- return project
67
+ return project, project_module, initial_modules
67
68
  except Exception as e:
68
69
  print(f"Error: Failed to import project: {e}")
69
70
  raise e from None
@@ -87,51 +88,15 @@ def validate_project(project: Project, config: BuildConfig):
87
88
  validate_engine(project.engine.data, config)
88
89
 
89
90
 
90
- def build_collection(project: Project, build_dir: Path, config: BuildConfig | None):
91
+ def build_collection(project: Project, build_dir: Path, config: BuildConfig | None, cache: CompileCache | None = None):
91
92
  site_dir = build_dir / "site"
92
93
  shutil.rmtree(site_dir, ignore_errors=True)
93
94
  site_dir.mkdir(parents=True, exist_ok=True)
94
95
 
95
- collection = build_project_to_collection(project, config)
96
+ collection = build_project_to_collection(project, config, cache=cache)
96
97
  collection.write(site_dir)
97
98
 
98
99
 
99
- def get_local_ips():
100
- hostname = socket.gethostname()
101
- local_ips = []
102
-
103
- with contextlib.suppress(socket.gaierror):
104
- local_ips.append(socket.gethostbyname(socket.getfqdn()))
105
-
106
- try:
107
- for info in socket.getaddrinfo(hostname, None):
108
- ip = info[4][0]
109
- if not ip.startswith("127.") and ":" not in ip:
110
- local_ips.append(ip)
111
- except socket.gaierror:
112
- pass
113
-
114
- return sorted(set(local_ips))
115
-
116
-
117
- def run_server(base_dir: Path, port: int = 8000):
118
- class DirectoryHandler(http.server.SimpleHTTPRequestHandler):
119
- def __init__(self, *args, **kwargs):
120
- super().__init__(*args, directory=str(base_dir), **kwargs)
121
-
122
- with socketserver.TCPServer(("", port), DirectoryHandler) as httpd:
123
- local_ips = get_local_ips()
124
- print(f"Server started on port {port}")
125
- print("Available on:")
126
- for ip in local_ips:
127
- print(f" http://{ip}:{port}")
128
- try:
129
- httpd.serve_forever()
130
- except KeyboardInterrupt:
131
- print("\nStopping server...")
132
- httpd.shutdown()
133
-
134
-
135
100
  def get_config(args: argparse.Namespace) -> BuildConfig:
136
101
  if hasattr(args, "optimize_minimal") and args.optimize_minimal:
137
102
  optimization_passes = MINIMAL_PASSES
@@ -236,7 +201,7 @@ def main():
236
201
  print("Python JIT is enabled")
237
202
 
238
203
  start_time = perf_counter()
239
- project = import_project(args.module)
204
+ project, project_module, core_module_names = import_project(args.module)
240
205
  end_time = perf_counter()
241
206
  if project is None:
242
207
  sys.exit(1)
@@ -254,10 +219,13 @@ def main():
254
219
  build_dir = Path(args.build_dir)
255
220
  start_time = perf_counter()
256
221
  config = get_config(args)
257
- build_collection(project, build_dir, config)
222
+ cache = CompileCache()
223
+ build_collection(project, build_dir, config, cache=cache)
258
224
  end_time = perf_counter()
259
225
  print(f"Build finished in {end_time - start_time:.2f}s")
260
- run_server(build_dir / "site", port=args.port)
226
+ run_server(
227
+ build_dir / "site", args.port, project_module.__name__, core_module_names, build_dir, config, cache
228
+ )
261
229
  elif args.command == "schema":
262
230
  print(json.dumps(get_project_schema(project), indent=2))
263
231
  elif args.command == "check":
sonolus/build/compile.py CHANGED
@@ -1,11 +1,13 @@
1
1
  from collections.abc import Callable, Sequence
2
2
  from concurrent.futures import Executor, Future
3
+ from threading import Event, Lock
3
4
 
4
5
  from sonolus.backend.finalize import cfg_to_engine_node
5
6
  from sonolus.backend.ir import IRConst, IRInstr
6
7
  from sonolus.backend.mode import Mode
8
+ from sonolus.backend.node import EngineNode
7
9
  from sonolus.backend.ops import Op
8
- from sonolus.backend.optimize.flow import BasicBlock
10
+ from sonolus.backend.optimize.flow import BasicBlock, hash_cfg
9
11
  from sonolus.backend.optimize.optimize import STANDARD_PASSES
10
12
  from sonolus.backend.optimize.passes import CompilerPass, OptimizerConfig, run_passes
11
13
  from sonolus.backend.visitor import compile_and_call_at_definition
@@ -25,6 +27,31 @@ from sonolus.script.internal.error import CompilationError
25
27
  from sonolus.script.num import _is_num
26
28
 
27
29
 
30
+ class CompileCache:
31
+ def __init__(self):
32
+ self._cache = {}
33
+ self._lock = Lock()
34
+ self._event = Event()
35
+
36
+ def get(self, entry_hash: int) -> EngineNode | None:
37
+ with self._lock:
38
+ if entry_hash not in self._cache:
39
+ self._cache[entry_hash] = None # Mark as being compiled
40
+ return None
41
+ while True:
42
+ with self._lock:
43
+ node = self._cache[entry_hash]
44
+ if node is not None:
45
+ return node
46
+ self._event.wait()
47
+
48
+ def set(self, entry_hash: int, node: EngineNode) -> None:
49
+ with self._lock:
50
+ self._cache[entry_hash] = node
51
+ self._event.set()
52
+ self._event.clear()
53
+
54
+
28
55
  def compile_mode(
29
56
  mode: Mode,
30
57
  rom: ReadOnlyMemory,
@@ -33,6 +60,7 @@ def compile_mode(
33
60
  passes: Sequence[CompilerPass] | None = None,
34
61
  thread_pool: Executor | None = None,
35
62
  validate_only: bool = False,
63
+ cache: CompileCache | None = None,
36
64
  ) -> dict:
37
65
  if passes is None:
38
66
  passes = STANDARD_PASSES
@@ -63,9 +91,19 @@ def compile_mode(
63
91
  return cb_info.name, {"index": 0, "order": cb_order}
64
92
  else:
65
93
  return cb_info.name, 0
66
- cfg = run_passes(cfg, passes, OptimizerConfig(mode=mode, callback=cb_info.name))
67
- node = cfg_to_engine_node(cfg)
68
- node_index = nodes.add(node)
94
+
95
+ if cache is not None:
96
+ cfg_hash = hash_cfg(cfg)
97
+ node = cache.get(cfg_hash)
98
+ if node is None:
99
+ optimized_cfg = run_passes(cfg, passes, OptimizerConfig(mode=mode, callback=cb_info.name))
100
+ node = cfg_to_engine_node(optimized_cfg)
101
+ cache.set(cfg_hash, node)
102
+ node_index = nodes.add(node)
103
+ else:
104
+ optimized_cfg = run_passes(cfg, passes, OptimizerConfig(mode=mode, callback=cb_info.name))
105
+ node = cfg_to_engine_node(optimized_cfg)
106
+ node_index = nodes.add(node)
69
107
 
70
108
  if arch is not None:
71
109
  cb_order = getattr(cb, "_callback_order_", 0)
@@ -0,0 +1,222 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import contextlib
5
+ import http.server
6
+ import importlib
7
+ import queue
8
+ import shlex
9
+ import socket
10
+ import socketserver
11
+ import sys
12
+ import textwrap
13
+ import threading
14
+ import traceback
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from time import perf_counter
18
+ from typing import TYPE_CHECKING, Protocol
19
+
20
+ from sonolus.backend.excepthook import print_simple_traceback
21
+ from sonolus.backend.utils import get_function, get_functions, get_tree_from_file
22
+ from sonolus.build.compile import CompileCache
23
+ from sonolus.script.internal.error import CompilationError
24
+
25
+ if TYPE_CHECKING:
26
+ from sonolus.script.project import BuildConfig
27
+
28
+ HELP_TEXT = """
29
+ [r]ebuild
30
+ [q]uit
31
+ """.strip()
32
+
33
+ HELP_TEXT = textwrap.dedent(HELP_TEXT)
34
+
35
+
36
+ class Command(Protocol):
37
+ def execute(
38
+ self,
39
+ project_module_name: str,
40
+ core_module_names: set[str],
41
+ build_dir: Path,
42
+ config: BuildConfig,
43
+ cache: CompileCache,
44
+ ) -> None: ...
45
+
46
+
47
+ @dataclass
48
+ class RebuildCommand:
49
+ def execute(
50
+ self,
51
+ project_module_name: str,
52
+ core_module_names: set[str],
53
+ build_dir: Path,
54
+ config: BuildConfig,
55
+ cache: CompileCache,
56
+ ):
57
+ from sonolus.build.cli import build_collection
58
+
59
+ for module_name in tuple(sys.modules):
60
+ if module_name not in core_module_names:
61
+ del sys.modules[module_name]
62
+
63
+ try:
64
+ project_module = importlib.import_module(project_module_name)
65
+ except Exception:
66
+ print(traceback.format_exc())
67
+ return
68
+
69
+ get_function.cache_clear()
70
+ get_tree_from_file.cache_clear()
71
+ get_functions.cache_clear()
72
+ print("Rebuilding...")
73
+ try:
74
+ start_time = perf_counter()
75
+ build_collection(project_module.project, build_dir, config, cache=cache)
76
+ end_time = perf_counter()
77
+ print(f"Rebuild completed in {end_time - start_time:.2f} seconds")
78
+ except CompilationError:
79
+ exc_info = sys.exc_info()
80
+ print_simple_traceback(*exc_info)
81
+
82
+
83
+ @dataclass
84
+ class ExitCommand:
85
+ def execute(
86
+ self,
87
+ project_module_name: str,
88
+ core_module_names: set[str],
89
+ build_dir: Path,
90
+ config: BuildConfig,
91
+ cache: CompileCache,
92
+ ):
93
+ print("Exiting...")
94
+ sys.exit(0)
95
+
96
+
97
+ def parse_dev_command(command_line: str) -> Command | None:
98
+ parser = argparse.ArgumentParser(prog="", add_help=False, exit_on_error=False)
99
+ subparsers = parser.add_subparsers(dest="cmd")
100
+
101
+ subparsers.add_parser("rebuild", aliases=["r"])
102
+ subparsers.add_parser("quit", aliases=["q"])
103
+
104
+ try:
105
+ args = parser.parse_args(shlex.split(command_line))
106
+ if args.cmd in {"rebuild", "r"}:
107
+ return RebuildCommand()
108
+ elif args.cmd in {"quit", "q"}:
109
+ return ExitCommand()
110
+ return None
111
+ except argparse.ArgumentError:
112
+ return None
113
+
114
+
115
+ def command_input_thread(command_queue: queue.Queue, stop_event: threading.Event, prompt_event: threading.Event):
116
+ print(f"\nAvailable commands:\n{HELP_TEXT}")
117
+
118
+ while not stop_event.is_set():
119
+ try:
120
+ prompt_event.wait()
121
+ prompt_event.clear()
122
+
123
+ if stop_event.is_set():
124
+ break
125
+
126
+ print("\n> ", end="", flush=True)
127
+ command_line = input()
128
+ if command_line.strip():
129
+ cmd = parse_dev_command(command_line.strip())
130
+ if cmd:
131
+ command_queue.put(cmd)
132
+ if isinstance(cmd, ExitCommand):
133
+ break
134
+ else:
135
+ print(f"Unknown command. Available commands:\n{HELP_TEXT}")
136
+ # Show prompt again
137
+ prompt_event.set()
138
+ except EOFError:
139
+ break
140
+ except Exception as e:
141
+ print(f"Error reading command: {e}")
142
+ break
143
+
144
+
145
+ def get_local_ips():
146
+ hostname = socket.gethostname()
147
+ local_ips = []
148
+
149
+ with contextlib.suppress(socket.gaierror):
150
+ local_ips.append(socket.gethostbyname(socket.getfqdn()))
151
+
152
+ try:
153
+ for info in socket.getaddrinfo(hostname, None):
154
+ ip = info[4][0]
155
+ if not ip.startswith("127.") and ":" not in ip:
156
+ local_ips.append(ip)
157
+ except socket.gaierror:
158
+ pass
159
+
160
+ return sorted(set(local_ips))
161
+
162
+
163
+ def run_server(
164
+ base_dir: Path,
165
+ port: int,
166
+ project_module_name: str | None,
167
+ core_module_names: set[str] | None,
168
+ build_dir: Path,
169
+ config: BuildConfig,
170
+ cache: CompileCache,
171
+ ):
172
+ interactive = project_module_name is not None and core_module_names is not None
173
+
174
+ class DirectoryHandler(http.server.SimpleHTTPRequestHandler):
175
+ def __init__(self, *args, **kwargs):
176
+ super().__init__(*args, directory=str(base_dir), **kwargs)
177
+
178
+ def log_message(self, fmt, *args):
179
+ sys.stdout.write("\r\033[K") # Clear line
180
+ sys.stdout.write(f"{self.address_string()} - - [{self.log_date_time_string()}] {fmt % args}\n")
181
+ if interactive:
182
+ sys.stdout.write("> ")
183
+ sys.stdout.flush()
184
+
185
+ with socketserver.TCPServer(("", port), DirectoryHandler) as httpd:
186
+ local_ips = get_local_ips()
187
+ print(f"Server started on port {port}")
188
+ print("Available on:")
189
+ for ip in local_ips:
190
+ print(f" http://{ip}:{port}")
191
+
192
+ if interactive:
193
+ threading.Thread(target=httpd.serve_forever, daemon=True).start()
194
+
195
+ command_queue = queue.Queue()
196
+ stop_event = threading.Event()
197
+ prompt_event = threading.Event()
198
+ input_thread = threading.Thread(
199
+ target=command_input_thread, args=(command_queue, stop_event, prompt_event), daemon=True
200
+ )
201
+ input_thread.start()
202
+
203
+ prompt_event.set()
204
+
205
+ try:
206
+ while True:
207
+ try:
208
+ cmd = command_queue.get(timeout=0.5)
209
+ cmd.execute(project_module_name, core_module_names, build_dir, config, cache)
210
+ prompt_event.set()
211
+ except queue.Empty:
212
+ continue
213
+ except KeyboardInterrupt:
214
+ print("\nStopping server...")
215
+ sys.exit(0)
216
+ finally:
217
+ httpd.shutdown()
218
+ stop_event.set()
219
+ prompt_event.set()
220
+ input_thread.join()
221
+ else:
222
+ httpd.serve_forever()