Skip to content

Commit 2153106

Browse files
authored
perf: move numeric manager term params to runtime buffers instead of plan_key constants (#36)
* perf: move numeric manager term params to runtime buffers instead of plan_key constants Numeric tuning arguments (termination thresholds, observation scales, reward gains, ...) were repr-baked into the generated kernel source and the plan key, so every tuning variant triggered a full ~1.2-30s kernel recompilation. Lower float term args into a fixed float32 shared input slot instead: only the layout fingerprint enters the plan key and the value is read at runtime, keeping tuning variants on one compiled plan. Reward weights are no longer pre-multiplied by ctrl_dt at build time; the dt scaling happens inside the evaluate kernel via ctx.dt, removing ctrl_dt from the plan key and the double-baking mismatch risk. Also improve startup logging: the manager env now logs each startup step (build plan, compile kernels with cache hit/miss classification, term specializations, warmup execution) with durations, and the runner logs each training startup phase. Kernel cache detection covers both numba cache layouts (NUMBA_CACHE_DIR set/unset), and fastsac worker processes configure INFO logging so startup timings are visible. Fixes #34 * test: drop exact read-plan source count in wbt numba test Scalar term args now lower into one runtime buffer source each, so the wbt read plan legitimately grows beyond the hardcoded count of four. The test's intent (preallocated, stable kernel inputs with the manager context present) is preserved without pinning the source count. * fix: address review feedback on worker logging and wbt scalar-buffer assertion - Worker logging setup no longer relies on basicConfig when the root logger already has handlers: raise the root level instead so startup INFO records surface through a preconfigured setup. - The wbt read-plan test now concretely asserts the presence of runtime scalar-buffer sources (TermScalarBuffer) instead of only leaving a comment, so regressions that bake scalar args back into the plan or generated source fail the test.
1 parent 8481250 commit 2153106

7 files changed

Lines changed: 264 additions & 45 deletions

File tree

‎motrix_env_core/src/motrix_env_core/numba/manager/compiler/codegen.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ def _generate_lane_bodies(
140140
[
141141
f"reward_value_{index} = {cls._term_call(term, index, 'ctx')}",
142142
f"reward_terms[env_id, {index}] = reward_value_{index}",
143-
f"weighted_reward_{index} = reward_value_{index} * reward_weights[{index}]",
143+
f"weighted_reward_{index} = reward_value_{index} * reward_weights[{index}] * ctx.dt",
144144
f"weighted_reward_terms[env_id, {index}] = weighted_reward_{index}",
145145
f"total_reward += weighted_reward_{index}",
146146
]

‎motrix_env_core/src/motrix_env_core/numba/manager/compiler/compiler.py‎

Lines changed: 163 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from collections.abc import Callable, Mapping
1313
from dataclasses import dataclass
1414
from pathlib import Path
15+
from time import perf_counter
1516
from types import MappingProxyType
1617
from typing import Any, get_args, get_origin, get_type_hints
1718

@@ -27,6 +28,7 @@
2728
flatten_kernel_data,
2829
is_kernel_data,
2930
iter_layout_leaves,
31+
kernel_data,
3032
lane_expression,
3133
map_proxy,
3234
proxy_symbol,
@@ -79,6 +81,20 @@ def _invalidate_term_cache() -> None:
7981
_TERM_CACHE.clear()
8082

8183

84+
@kernel_data
85+
class TermScalarBuffer:
86+
"""Runtime buffer slot for one numeric term argument.
87+
88+
Numeric tuning arguments (thresholds, scales, gains, ...) are lowered into
89+
a fixed-dtype shared input slot: only the type/layout fingerprint enters
90+
the plan key, and the value itself is read at runtime. This keeps
91+
tuning-variant manager configs on one compiled plan instead of triggering
92+
a full recompile per value.
93+
"""
94+
95+
value: np.float32
96+
97+
8298
@dataclass(frozen=True)
8399
class ManagerSimInput:
84100
"""One compiled simulator input exposed through ``ManagerContext.sim``."""
@@ -111,6 +127,7 @@ def __init__(self, env: ManagerEnv):
111127
self._plan_parts.append(("sim_inputs", sim_input_fingerprint))
112128

113129
def build(self) -> CompiledManagerProgram:
130+
build_started = perf_counter()
114131
self._resolve_runtime_terms()
115132
observation_groups = self._env.observation_groups
116133
reward_terms = self._env._reward_terms
@@ -130,9 +147,17 @@ def build(self) -> CompiledManagerProgram:
130147
)
131148
self._validate_observation_spaces(observation_groups)
132149

133-
kernel_reward_weights = np.asarray(reward_weights, dtype=np.float32) * np.float32(self._env.cfg.ctrl_dt)
134-
self._plan_parts.append(("manager_context_dt", np.float32(self._env.cfg.ctrl_dt)))
150+
# Reward weights stay unscaled in the runtime buffer; the dt scaling
151+
# happens inside the kernel via ctx.dt, so ctrl_dt does not need to
152+
# participate in the plan key.
153+
kernel_reward_weights = np.asarray(reward_weights, dtype=np.float32)
135154
plan_key = hashlib.sha256(repr(tuple(self._plan_parts)).encode()).hexdigest()
155+
logger.info(
156+
"Manager startup %s: build plan finished in %.3fs (plan_key=%.12s)",
157+
self._env_name(),
158+
perf_counter() - build_started,
159+
plan_key,
160+
)
136161
source = KernelSourceGenerator(self._flat_input_count).generate(
137162
observation_terms,
138163
observation_layout,
@@ -146,8 +171,28 @@ def build(self) -> CompiledManagerProgram:
146171
)
147172
generated_filename = self._materialize_source(source, plan_key)
148173
kernels = _KERNEL_CACHE.get(plan_key)
149-
if kernels is None:
150-
logger.debug("Manager kernel cache miss: plan_key=%s", plan_key)
174+
if kernels is not None:
175+
logger.info(
176+
"Manager startup %s: kernels reused (cache=in-process, plan_key=%.12s)",
177+
self._env_name(),
178+
plan_key,
179+
)
180+
else:
181+
disk_cache = self._has_generated_kernel_cache(plan_key)
182+
if disk_cache:
183+
logger.info(
184+
"Manager startup %s: compiled kernel cache found, loading (cache=disk, plan_key=%.12s)",
185+
self._env_name(),
186+
plan_key,
187+
)
188+
else:
189+
logger.info(
190+
"Manager startup %s: no cache for this plan, first compile may take a while "
191+
"(cache=miss, plan_key=%.12s)",
192+
self._env_name(),
193+
plan_key,
194+
)
195+
compile_started = perf_counter()
151196
try:
152197
kernels = self._compile_kernels(source, generated_filename)
153198
except (ImportError, OSError, RuntimeError, TypeError, ValueError) as error:
@@ -156,8 +201,12 @@ def build(self) -> CompiledManagerProgram:
156201
generated_filename = self._materialize_source(source, plan_key)
157202
kernels = self._compile_kernels(source, generated_filename)
158203
_KERNEL_CACHE[plan_key] = kernels
159-
else:
160-
logger.debug("Manager kernel in-process cache hit: plan_key=%s", plan_key)
204+
logger.info(
205+
"Manager startup %s: compile kernels finished in %.3fs (cache=%s)",
206+
self._env_name(),
207+
perf_counter() - compile_started,
208+
"disk" if disk_cache else "miss",
209+
)
161210
evaluate_kernel, observe_kernel, reset_kernel = kernels
162211

163212
layout = ManagerLayout(
@@ -271,6 +320,7 @@ def _resolve_reset(
271320
symbol = f"term_reset_{term_index}"
272321
self._term_functions[symbol] = self._generated_term(function, dispatcher)
273322
expressions = []
323+
plan_expressions = []
274324
prepared_indices = []
275325
for index, value in enumerate(term.args):
276326
if is_kernel_data(value):
@@ -285,15 +335,20 @@ def _resolve_reset(
285335
)
286336
prepared_indices.append(prepared_index)
287337
expressions.append(expression)
338+
plan_expressions.append(expression)
288339
else:
289-
prepared_indices.append(None)
290-
expressions.append(repr(value))
340+
expression, _, plan_part, prepared_index = self._resolve_plain_arg(
341+
f"sim_reset.{name}.args[{index}]", value
342+
)
343+
prepared_indices.append(prepared_index)
344+
expressions.append(expression)
345+
plan_expressions.append(plan_part)
291346
self._plan_parts.append(
292347
(
293348
"sim_reset",
294349
name,
295350
self._function_fingerprint(function),
296-
tuple(expressions),
351+
plan_expressions,
297352
repr(descriptors),
298353
fields,
299354
)
@@ -473,7 +528,9 @@ def _resolve_observation_dispatch(
473528
f"ManagerContext and np.ndarray (conventionally named ctx and out)."
474529
)
475530
expressions = []
531+
plan_expressions = []
476532
prepared_indices = []
533+
warmup_values = []
477534
for index, value in enumerate(term.args):
478535
if is_kernel_data(value):
479536
_, tree_def = flatten_kernel_data(value)
@@ -485,23 +542,30 @@ def _resolve_observation_dispatch(
485542
)
486543
prepared_indices.append(prepared_index)
487544
expressions.append(expression)
545+
plan_expressions.append(expression)
546+
warmup_values.append(value)
488547
else:
489-
prepared_indices.append(None)
490-
expressions.append(repr(value))
548+
expression, warmup, plan_part, prepared_index = self._resolve_plain_arg(
549+
f"observation.{group}.{name}.args[{index}]", value
550+
)
551+
prepared_indices.append(prepared_index)
552+
expressions.append(expression)
553+
plan_expressions.append(plan_part)
554+
warmup_values.append(warmup)
491555
expressions = tuple(expressions)
492556
dispatcher = self._compile_term(function)
493557
symbol = f"term_observation_{term_index}"
494558
self._term_functions[symbol] = self._generated_term(function, dispatcher)
495559
self._plan_parts.append(
496-
(group, name, "observation_dispatch", self._function_fingerprint(function), output_size, expressions)
560+
(group, name, "observation_dispatch", self._function_fingerprint(function), output_size, plan_expressions)
497561
)
498562
return PreparedInvocation(
499563
f"{group}.{name}",
500564
"observation",
501565
dispatcher,
502566
None,
503567
args_expressions=expressions,
504-
args_values=term.args,
568+
args_values=tuple(warmup_values),
505569
args_prepared_indices=tuple(prepared_indices),
506570
output_size=output_size,
507571
)
@@ -574,7 +638,9 @@ def _resolve_dispatch_term(
574638
f"(conventionally named ctx)."
575639
)
576640
expressions = []
641+
plan_expressions = []
577642
prepared_indices = []
643+
warmup_values = []
578644
for index, value in enumerate(term.args):
579645
if is_kernel_data(value):
580646
_, tree_def = flatten_kernel_data(value)
@@ -586,20 +652,27 @@ def _resolve_dispatch_term(
586652
)
587653
prepared_indices.append(prepared_index)
588654
expressions.append(expression)
655+
plan_expressions.append(expression)
656+
warmup_values.append(value)
589657
else:
590-
prepared_indices.append(None)
591-
expressions.append(repr(value))
658+
expression, warmup, plan_part, prepared_index = self._resolve_plain_arg(
659+
f"{group}.{name}.args[{index}]", value
660+
)
661+
prepared_indices.append(prepared_index)
662+
expressions.append(expression)
663+
plan_expressions.append(plan_part)
664+
warmup_values.append(warmup)
592665
dispatcher = self._compile_term(function)
593666
symbol = f"term_{group}_{term_index}"
594667
self._term_functions[symbol] = self._generated_term(function, dispatcher)
595-
self._plan_parts.append((group, name, "dispatch", self._function_fingerprint(function), expressions))
668+
self._plan_parts.append((group, name, "dispatch", self._function_fingerprint(function), plan_expressions))
596669
return PreparedInvocation(
597670
f"{group}.{name}",
598671
group,
599672
dispatcher,
600673
None,
601674
args_expressions=tuple(expressions),
602-
args_values=term.args,
675+
args_values=tuple(warmup_values),
603676
args_prepared_indices=tuple(prepared_indices),
604677
)
605678

@@ -681,6 +754,38 @@ def _register_prepared(
681754
self._prepared_types[proxy_symbol(proxy)] = proxy
682755
return prepared_index, lane_expression(layout, input_offset)
683756

757+
def _scalar_buffer_arg(self, source_name: str, value: float) -> tuple[str, np.float32]:
758+
"""Lower one numeric term argument into a runtime scalar buffer slot.
759+
760+
Returns the kernel-lane argument expression and the normalized warmup
761+
value. Only the fixed float32 layout enters the plan; the value itself
762+
is read from the input slot at runtime.
763+
"""
764+
scalar = np.float32(value)
765+
wrapped = TermScalarBuffer(scalar)
766+
_, tree_def = flatten_kernel_data(wrapped)
767+
layout = self._kernel_data_lowering.lower(
768+
tree_def,
769+
context=source_name,
770+
force_shared=True,
771+
)
772+
prepared_index, _ = self._register_prepared(source_name, wrapped, TermScalarBuffer, layout)
773+
leaf = iter_layout_leaves(layout)[0]
774+
return f"input_{self._input_offsets[prepared_index] + leaf.slot_index}", scalar
775+
776+
def _resolve_plain_arg(self, source_name: str, value: Any) -> tuple[str | None, Any, str, int | None]:
777+
"""Resolve one non-kernel-data term argument.
778+
779+
Float-like scalars become runtime scalar buffers; every
780+
other scalar stays a compile-time constant. Returns the kernel-lane
781+
argument expression, the warmup value, the plan fingerprint part, and
782+
the prepared index (``None`` for both plain scalars and buffers).
783+
"""
784+
if isinstance(value, (float, np.floating)):
785+
expression, warmup = self._scalar_buffer_arg(source_name, value)
786+
return expression, warmup, "scalar_buffer(np.float32)", None
787+
return repr(value), value, repr(value), None
788+
684789
def _validate_observation_spaces(self, groups: dict[str, ObservationGroupEntry]) -> None:
685790
expected_policy = self._env.policy_observation_space.shape
686791
if expected_policy != (groups["policy"].size,):
@@ -704,9 +809,7 @@ def _validate_observation_spaces(self, groups: dict[str, ObservationGroupEntry])
704809

705810
@staticmethod
706811
def _materialize_source(source: str, plan_key: str) -> str:
707-
cache_dir = os.environ.get("NUMBA_CACHE_DIR")
708-
if not cache_dir:
709-
cache_dir = os.path.join(os.path.expanduser("~"), ".cache", "motrixlab", "numba-manager")
812+
cache_dir = NumbaKernelCompiler._generated_cache_dir()
710813
path = Path(cache_dir) / "generated" / f"{plan_key}.py"
711814
path.parent.mkdir(parents=True, exist_ok=True)
712815
if not path.exists() or path.read_text(encoding="utf-8") != source:
@@ -724,23 +827,53 @@ def _materialize_source(source: str, plan_key: str) -> str:
724827
return str(path)
725828

726829
@staticmethod
727-
def _invalidate_generated_cache(plan_key: str) -> None:
830+
def _generated_cache_dir() -> str:
728831
cache_dir = os.environ.get("NUMBA_CACHE_DIR")
729832
if not cache_dir:
730833
cache_dir = os.path.join(os.path.expanduser("~"), ".cache", "motrixlab", "numba-manager")
834+
return cache_dir
835+
836+
@staticmethod
837+
def _generated_cache_paths(plan_key: str) -> tuple[Path, ...]:
838+
"""Return compiled-kernel cache files for one plan key.
839+
840+
Numba stores the cached specializations of the generated module next to
841+
its cache locator: under ``generated_<hash>/`` when ``NUMBA_CACHE_DIR``
842+
is set, and under ``generated/__pycache__/`` (beside the generated
843+
source) when it is not. Both layouts are covered so probe and
844+
invalidation agree with where the files actually live.
845+
"""
846+
cache_dir = Path(NumbaKernelCompiler._generated_cache_dir())
847+
paths: list[Path] = []
848+
for pattern in (f"generated_*/*{plan_key}*", f"generated/__pycache__/*{plan_key}*"):
849+
try:
850+
paths.extend(cache_dir.glob(pattern))
851+
except OSError:
852+
logger.debug("Unable to scan generated kernel cache: %s", cache_dir, exc_info=True)
853+
return tuple(paths)
854+
855+
@staticmethod
856+
def _has_generated_kernel_cache(plan_key: str) -> bool:
857+
"""Return whether compiled-kernel cache files exist for one plan key."""
858+
return any(path.suffix in {".nbi", ".nbc"} for path in NumbaKernelCompiler._generated_cache_paths(plan_key))
859+
860+
@staticmethod
861+
def _invalidate_generated_cache(plan_key: str) -> None:
862+
cache_dir = NumbaKernelCompiler._generated_cache_dir()
731863
source_path = Path(cache_dir) / "generated" / f"{plan_key}.py"
732864
try:
733865
source_path.unlink(missing_ok=True)
734866
except OSError:
735867
logger.debug("Unable to remove invalid generated source: %s", source_path, exc_info=True)
736-
for cache_path in Path(cache_dir).glob(f"generated_*/*{plan_key}*"):
868+
for cache_path in NumbaKernelCompiler._generated_cache_paths(plan_key):
737869
try:
738870
cache_path.unlink(missing_ok=True)
739871
except OSError:
740872
logger.debug("Unable to remove invalid generated cache: %s", cache_path, exc_info=True)
741873

742874
def compile_specializations(self, inputs: tuple[Any, ...]) -> None:
743875
"""Compile all term and fused-kernel specializations for the environment."""
876+
started = perf_counter()
744877
try:
745878
self._compile_specializations_once(inputs)
746879
except (
@@ -760,6 +893,14 @@ def compile_specializations(self, inputs: tuple[Any, ...]) -> None:
760893
_KERNEL_CACHE.pop(plan_key, None)
761894
self._env._task_program = self.build().task
762895
self._compile_specializations_once(inputs)
896+
logger.info(
897+
"Manager startup %s: term specializations finished in %.3fs",
898+
self._env_name(),
899+
perf_counter() - started,
900+
)
901+
902+
def _env_name(self) -> str:
903+
return type(self._env).__name__
763904

764905
def _compile_specializations_once(self, inputs: tuple[Any, ...]) -> None:
765906
program = self._env._compiled_manager_program

0 commit comments

Comments
 (0)