mirror of
https://github.com/pytorch/pytorch.git
synced 2025-10-21 05:34:18 +08:00
This PR combines a number of cleanups in one PR. If any of the specific cleanups don't seem to make sense, let me know and I can remove them. Cleanups - This PR adds a set of test suites for the config module code, which handles basically all the APIs and ways it is used. Please let me know if you see anything critical that is not tested that I missed. This test suite is primarily used as the regression test suite for later changes in this diff. Note that there is some dynamo specific testing of the config module, but it isn't as verbose. - I removed all internal usage of shallow_copy_dict. Those usages could all use the deep copy, and did not depend on the reference behavior of certain config values that shallow_copy_dict allows. - I removed shallow copy semantics for configuration with a deprecation warning. I think this requires a release note, so hopefully I did that correctly. Let me know if we want to continue to expose shallow copy value semantics, but I just can't find a case where I expect anyone would want it. It also complicated later internal changes to the API (i.e. breaking apart various layers of the config changes). - I fixed what I believe is a bug in how hashes are calculated on configs. In particular, if you got the hash, then made a config change, and then got the hash again, it would not update the hash. @oulgen, please let me know if I'm misunderstanding this behavior and it is desired. - I switched our multiple implementations of iterating through the dictionary to a single one. This is primarily to make later changes easier, but it also makes it clear how inconsistent our various config ignoring options are. Let me know if people would be interested in me unifying the various options for ignoring config values. - I updated the test patcher (not the performance critical one, just the normal one), to use __setattr__ and __getattr__ to remove direct API access to the underlying config fetcher. For release notes, Not sure exactly how to communicate this, but something like "ConfigModule.to_dict, and ConfigModule.shallow_copy_dict no longer retain their shallow copy semantics, which allowed reference values objects to be modified. If you wish to modify the config object, call load_config explicitly". Pull Request resolved: https://github.com/pytorch/pytorch/pull/138377 Approved by: https://github.com/ezyang, https://github.com/jansel, https://github.com/jovianjaison
421 lines
15 KiB
Python
421 lines
15 KiB
Python
import contextlib
|
|
import copy
|
|
import hashlib
|
|
import inspect
|
|
import io
|
|
import pickle
|
|
import tokenize
|
|
import unittest
|
|
import warnings
|
|
from types import FunctionType, ModuleType
|
|
from typing import Any, Callable, Dict, List, NoReturn, Optional, Set, Union
|
|
from typing_extensions import deprecated
|
|
from unittest import mock
|
|
|
|
|
|
# Types saved/loaded in configs
|
|
CONFIG_TYPES = (int, float, bool, type(None), str, list, set, tuple, dict)
|
|
|
|
|
|
def install_config_module(module: ModuleType) -> None:
|
|
"""
|
|
Converts a module-level config into a `ConfigModule()`.
|
|
|
|
See _config_typing.pyi for instructions on how to get the converted module to typecheck.
|
|
"""
|
|
|
|
class ConfigModuleInstance(ConfigModule):
|
|
_bypass_keys = set({"_is_dirty", "_hash_digest"})
|
|
|
|
def visit(
|
|
source: Union[ModuleType, type],
|
|
dest: Union[ModuleType, SubConfigProxy],
|
|
prefix: str,
|
|
) -> None:
|
|
"""Walk the module structure and move everything to module._config"""
|
|
for key, value in list(source.__dict__.items()):
|
|
if (
|
|
key.startswith("__")
|
|
or isinstance(value, (ModuleType, FunctionType))
|
|
or (hasattr(value, "__module__") and value.__module__ == "typing")
|
|
):
|
|
continue
|
|
|
|
name = f"{prefix}{key}"
|
|
if isinstance(value, CONFIG_TYPES):
|
|
config[name] = value
|
|
default[name] = value
|
|
if dest is module:
|
|
delattr(module, key)
|
|
elif isinstance(value, type):
|
|
assert value.__module__ == module.__name__
|
|
# a subconfig with `class Blah:` syntax
|
|
proxy = SubConfigProxy(module, f"{name}.")
|
|
visit(value, proxy, f"{name}.")
|
|
if dest is module:
|
|
setattr(dest, key, proxy)
|
|
else:
|
|
dest.__dict__[key] = proxy
|
|
else:
|
|
raise AssertionError(f"Unhandled config {key}={value} ({type(value)})")
|
|
|
|
config: Dict[str, Any] = {}
|
|
default: Dict[str, Any] = {}
|
|
|
|
compile_ignored_keys = get_assignments_with_compile_ignored_comments(module)
|
|
|
|
visit(module, module, "")
|
|
module._config = config # type: ignore[attr-defined]
|
|
module._default = default # type: ignore[attr-defined]
|
|
module._allowed_keys = set(config.keys()) # type: ignore[attr-defined]
|
|
module._compile_ignored_keys = compile_ignored_keys # type: ignore[attr-defined]
|
|
module.__class__ = ConfigModuleInstance
|
|
module._is_dirty = True # type: ignore[attr-defined]
|
|
module._hash_digest = None # type: ignore[attr-defined]
|
|
|
|
|
|
COMPILE_IGNORED_MARKER = "@compile_ignored"
|
|
|
|
|
|
# Gets all the keys (i.e. assignments) with a @compile_ignored comment
|
|
def get_assignments_with_compile_ignored_comments(module: ModuleType) -> Set[str]:
|
|
source_code = inspect.getsource(module)
|
|
assignments = set()
|
|
|
|
# Tokenize the source code to retrieve comments
|
|
tokens = tokenize.tokenize(io.BytesIO(source_code.encode("utf-8")).readline)
|
|
current_comment = "", -1
|
|
prev_name = ""
|
|
|
|
for token in tokens:
|
|
if token.type == tokenize.COMMENT:
|
|
prev_name = ""
|
|
maybe_current = token.string.strip()
|
|
if COMPILE_IGNORED_MARKER in maybe_current:
|
|
assert current_comment == (
|
|
"",
|
|
-1,
|
|
), f"unconsumed {COMPILE_IGNORED_MARKER}"
|
|
current_comment = maybe_current, token.start[0]
|
|
elif token.type == tokenize.NAME:
|
|
# Only accept the first name token, to handle if you have
|
|
# something like foo: Bar = ...
|
|
if not prev_name:
|
|
prev_name = token.string
|
|
elif token.type == tokenize.OP and token.string == "=":
|
|
# Check if the current assignment follows a comment
|
|
# with COMPILE_IGNORED_MARKER
|
|
if (
|
|
COMPILE_IGNORED_MARKER in current_comment[0]
|
|
and current_comment[1] == token.start[0] - 1
|
|
):
|
|
assignments.add(prev_name)
|
|
current_comment = "", -1 # reset
|
|
prev_name = ""
|
|
assert current_comment == ("", -1), f"unconsumed {COMPILE_IGNORED_MARKER}"
|
|
return assignments
|
|
|
|
|
|
class ConfigModule(ModuleType):
|
|
# NOTE: This should be kept in sync with _config_typing.pyi.
|
|
|
|
# The default values of the configuration settings. This can be used to
|
|
# determine if the config has been changed or not.
|
|
_default: Dict[str, Any]
|
|
# The actual configuration settings. E.g., torch._dynamo.config.debug
|
|
# would live as "debug" in the key, and torch._inductor.config.triton.cudagraphs
|
|
# maps as "triton.cudagraphs"
|
|
_config: Dict[str, Any]
|
|
_allowed_keys: Set[str]
|
|
_bypass_keys: Set[str]
|
|
_compile_ignored_keys: Set[str]
|
|
_is_dirty: bool
|
|
_hash_digest: Optional[bytes]
|
|
|
|
def __init__(self) -> None:
|
|
raise NotImplementedError(
|
|
f"use {__name__}.install_config_module(sys.modules[__name__])"
|
|
)
|
|
|
|
def __setattr__(self, name: str, value: object) -> None:
|
|
if name in self._bypass_keys:
|
|
super().__setattr__(name, value)
|
|
elif name not in self._allowed_keys:
|
|
raise AttributeError(f"{self.__name__}.{name} does not exist")
|
|
else:
|
|
self._config[name] = value
|
|
self._is_dirty = True
|
|
|
|
def __getattr__(self, name: str) -> Any:
|
|
try:
|
|
return self._config[name]
|
|
except KeyError as e:
|
|
# make hasattr() work properly
|
|
raise AttributeError(f"{self.__name__}.{name} does not exist") from e
|
|
|
|
def __delattr__(self, name: str) -> None:
|
|
self._is_dirty = True
|
|
# must support delete because unittest.mock.patch deletes
|
|
# then recreate things
|
|
del self._config[name]
|
|
|
|
def _get_dict(
|
|
self,
|
|
ignored_keys: Optional[List[str]] = None,
|
|
ignored_prefixes: Optional[List[str]] = None,
|
|
skip_default: bool = False,
|
|
) -> Dict[str, Any]:
|
|
"""Export a dictionary of current configuration keys and values.
|
|
|
|
This function is design to provide a single point which handles
|
|
accessing config options and exporting them into a dictionary.
|
|
This is used by a number of different user facing export methods
|
|
which all have slightly different semantics re: how and what to
|
|
skip.
|
|
|
|
Arguments:
|
|
ignored_keys are keys that should not be exported.
|
|
ignored_prefixes are prefixes that if a key matches should
|
|
not be exported
|
|
skip_default does two things. One if a key has not been modified
|
|
it skips it. The other is it modified the logging behaviour
|
|
to match what codegen already did for modified skipped keys
|
|
"""
|
|
config: Dict[str, Any] = {}
|
|
for key in self._config:
|
|
if ignored_keys and key in ignored_keys:
|
|
if skip_default and self._config[key] != self._default[key]:
|
|
warnings.warn(
|
|
f"Skipping serialization of {key} value {self._config[key]}"
|
|
)
|
|
continue
|
|
if ignored_prefixes:
|
|
if any(key.startswith(prefix) for prefix in ignored_prefixes):
|
|
continue
|
|
if skip_default and self._config[key] == self._default[key]:
|
|
continue
|
|
config[key] = copy.deepcopy(self._config[key])
|
|
return config
|
|
|
|
def save_config(self) -> bytes:
|
|
"""Convert config to a pickled blob"""
|
|
return pickle.dumps(
|
|
self._get_dict(ignored_keys=self._config.get("_save_config_ignore", ())),
|
|
protocol=2,
|
|
)
|
|
|
|
def save_config_portable(self) -> Dict[str, Any]:
|
|
"""Convert config to portable format"""
|
|
prefixes = ["_"]
|
|
prefixes.extend(self._config["_cache_config_ignore_prefix"])
|
|
return self._get_dict(ignored_prefixes=prefixes)
|
|
|
|
def codegen_config(self) -> str:
|
|
"""Convert config to Python statements that replicate current config.
|
|
This does NOT include config settings that are at default values.
|
|
"""
|
|
lines = []
|
|
mod = self.__name__
|
|
for k, v in self._get_dict(
|
|
ignored_keys=self._config.get("_save_config_ignore"), skip_default=True
|
|
).items():
|
|
lines.append(f"{mod}.{k} = {v!r}")
|
|
return "\n".join(lines)
|
|
|
|
def get_hash(self) -> bytes:
|
|
"""Hashes the configs that are not compile_ignored"""
|
|
if self._is_dirty or self._hash_digest is None:
|
|
dict_to_hash = self._get_dict(ignored_keys=list(self._compile_ignored_keys))
|
|
string_to_hash = repr(sorted(dict_to_hash.items()))
|
|
self._hash_digest = hashlib.md5(string_to_hash.encode("utf-8")).digest()
|
|
self._is_dirty = False
|
|
return self._hash_digest
|
|
|
|
@deprecated(
|
|
"`config.to_dict()` has been deprecated. It no longer changes the underlying config."
|
|
" use `config.get_config_copy()` instead if you just want a copy of the config, or "
|
|
"config.load_config if you need mutable access",
|
|
category=FutureWarning,
|
|
)
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return self.get_config_copy()
|
|
|
|
@deprecated(
|
|
"`config.shallow_copy_dict()` has been deprecated. It no longer changes the underlying config."
|
|
" use `config.get_config_copy()` instead if you just want a copy of the config, or "
|
|
"config.load_config if you need mutable access",
|
|
category=FutureWarning,
|
|
)
|
|
def shallow_copy_dict(self) -> Dict[str, Any]:
|
|
return self.get_config_copy()
|
|
|
|
def load_config(self, maybe_pickled_config: Union[bytes, Dict[str, Any]]) -> None:
|
|
"""Restore from a prior call to save_config() or shallow_copy_dict()"""
|
|
if not isinstance(maybe_pickled_config, dict):
|
|
config = pickle.loads(maybe_pickled_config)
|
|
else:
|
|
config = maybe_pickled_config
|
|
self._config.update(config)
|
|
|
|
def get_config_copy(self) -> Dict[str, Any]:
|
|
return self._get_dict()
|
|
|
|
def patch(
|
|
self,
|
|
arg1: Optional[Union[str, Dict[str, Any]]] = None,
|
|
arg2: Any = None,
|
|
**kwargs: Dict[str, Any],
|
|
) -> "ContextDecorator":
|
|
"""
|
|
Decorator and/or context manager to make temporary changes to a config.
|
|
|
|
As a decorator:
|
|
|
|
@config.patch("name", val)
|
|
@config.patch(name1=val1, name2=val2)
|
|
@config.patch({"name1": val1, "name2", val2})
|
|
def foo(...):
|
|
...
|
|
|
|
As a context manager:
|
|
|
|
with config.patch("name", val):
|
|
...
|
|
"""
|
|
changes: Dict[str, Any]
|
|
if arg1 is not None:
|
|
if arg2 is not None:
|
|
assert isinstance(arg1, str)
|
|
# patch("key", True) syntax
|
|
changes = {arg1: arg2}
|
|
else:
|
|
assert isinstance(arg1, dict)
|
|
# patch({"key": True}) syntax
|
|
changes = arg1
|
|
assert not kwargs
|
|
else:
|
|
# patch(key=True) syntax
|
|
changes = kwargs
|
|
assert arg2 is None
|
|
assert isinstance(changes, dict), f"expected `dict` got {type(changes)}"
|
|
prior: Dict[str, Any] = {}
|
|
config = self
|
|
|
|
class ConfigPatch(ContextDecorator):
|
|
def __enter__(self) -> None:
|
|
assert not prior
|
|
for key in changes.keys():
|
|
# KeyError on invalid entry
|
|
prior[key] = config.__getattr__(key)
|
|
for k, v in changes.items():
|
|
config.__setattr__(k, v)
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb): # type: ignore[no-untyped-def]
|
|
for k, v in prior.items():
|
|
config.__setattr__(k, v)
|
|
prior.clear()
|
|
|
|
return ConfigPatch()
|
|
|
|
def _make_closure_patcher(self, **changes: Dict[str, Any]) -> Any:
|
|
"""
|
|
A lower-overhead version of patch() for things on the critical path.
|
|
|
|
Usage:
|
|
|
|
# do this off the critical path
|
|
change_fn = config.make_closure_patcher(foo=True)
|
|
|
|
...
|
|
|
|
revert = change_fn()
|
|
try:
|
|
...
|
|
finally:
|
|
revert()
|
|
|
|
"""
|
|
config = self._config
|
|
|
|
def change() -> Callable[[], None]:
|
|
prior = {k: config[k] for k in changes}
|
|
config.update(changes)
|
|
|
|
def revert() -> None:
|
|
config.update(prior)
|
|
|
|
return revert
|
|
|
|
return change
|
|
|
|
|
|
class ContextDecorator(contextlib.ContextDecorator):
|
|
"""
|
|
Same as contextlib.ContextDecorator, but with support for
|
|
`unittest.TestCase`
|
|
"""
|
|
|
|
def __enter__(self) -> None:
|
|
raise NotImplementedError("NYI")
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb) -> NoReturn: # type: ignore[no-untyped-def]
|
|
raise NotImplementedError("NYI")
|
|
|
|
def __call__(self, func: Callable[[Any], Any]) -> Any:
|
|
if isinstance(func, type) and issubclass(func, unittest.TestCase):
|
|
|
|
class _TestCase(func): # type: ignore[valid-type, misc]
|
|
@classmethod
|
|
def setUpClass(cls) -> None:
|
|
self.__enter__()
|
|
try:
|
|
super().setUpClass()
|
|
except Exception:
|
|
self.__exit__(None, None, None)
|
|
raise
|
|
|
|
@classmethod
|
|
def tearDownClass(cls) -> None:
|
|
try:
|
|
super().tearDownClass()
|
|
finally:
|
|
self.__exit__(None, None, None)
|
|
|
|
_TestCase.__name__ = func.__name__
|
|
_TestCase.__qualname__ = func.__qualname__
|
|
_TestCase.__module__ = func.__module__
|
|
|
|
return _TestCase
|
|
|
|
return super().__call__(func)
|
|
|
|
|
|
class SubConfigProxy:
|
|
"""
|
|
Shim to redirect to main config.
|
|
`config.triton.cudagraphs` maps to _config["triton.cudagraphs"]
|
|
"""
|
|
|
|
def __init__(self, config: object, prefix: str):
|
|
# `super().__setattr__` to bypass custom `__setattr__`
|
|
super().__setattr__("_config", config)
|
|
super().__setattr__("_prefix", prefix)
|
|
|
|
def __setattr__(self, name: str, value: object) -> None:
|
|
return self._config.__setattr__(self._prefix + name, value)
|
|
|
|
def __getattr__(self, name: str) -> Any:
|
|
return self._config.__getattr__(self._prefix + name)
|
|
|
|
def __delattr__(self, name: str) -> None:
|
|
return self._config.__delattr__(self._prefix + name)
|
|
|
|
|
|
def patch_object(obj: object, name: str, value: object) -> object:
|
|
"""
|
|
Workaround `mock.patch.object` issue with ConfigModule
|
|
"""
|
|
if isinstance(obj, ConfigModule):
|
|
return obj.patch(name, value)
|
|
return mock.patch.object(obj, name, value)
|