meltygui 0.1.0__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.
- meltygui/__init__.py +107 -0
- meltygui/accounts/__init__.py +0 -0
- meltygui/accounts/internet_accounts.py +1355 -0
- meltygui/chat/__init__.py +91 -0
- meltygui/chat/activity.py +75 -0
- meltygui/chat/backends.py +36 -0
- meltygui/chat/chat_interface.py +732 -0
- meltygui/chat/chat_proxy.py +352 -0
- meltygui/chat/codex_proxy.py +592 -0
- meltygui/chat/codex_settings.py +100 -0
- meltygui/chat/codex_transport.py +60 -0
- meltygui/chat/command_parser.py +204 -0
- meltygui/chat/images.py +227 -0
- meltygui/chat/messages.py +417 -0
- meltygui/chat/metadata.py +139 -0
- meltygui/chat/writer_locks.py +64 -0
- meltygui/code/__init__.py +0 -0
- meltygui/code/basic_converters.py +533 -0
- meltygui/code/chain_converters.py +2111 -0
- meltygui/code/code_checks.py +2209 -0
- meltygui/code/core_syntax.py +1430 -0
- meltygui/code/file_converters.py +1933 -0
- meltygui/code/fileref.py +702 -0
- meltygui/code/hotswap_guard.py +144 -0
- meltygui/code/libcst_conversion.py +9724 -0
- meltygui/code/live_instrument.py +392 -0
- meltygui/code/live_view.py +2490 -0
- meltygui/code/melty_scan.py +2684 -0
- meltygui/code/new_codecs.py +1255 -0
- meltygui/code/new_converters.py +3017 -0
- meltygui/code/project_code.py +278 -0
- meltygui/code/source_context.py +63 -0
- meltygui/code/symbol_roster.py +1588 -0
- meltygui/code/syntax_check.py +34 -0
- meltygui/code/syntax_check_worker.py +114 -0
- meltygui/completion/__init__.py +0 -0
- meltygui/completion/fim.py +1232 -0
- meltygui/completion/fim_context.py +481 -0
- meltygui/completion/providers/__init__.py +0 -0
- meltygui/completion/providers/anthropic_oauth.py +446 -0
- meltygui/completion/providers/anthropic_requests.py +69 -0
- meltygui/completion/providers/claude.py +203 -0
- meltygui/completion/providers/claude_usage.py +499 -0
- meltygui/completion/providers/codex_accounts.py +180 -0
- meltygui/completion/providers/copilot.py +617 -0
- meltygui/completion/providers/oauth_popup.py +220 -0
- meltygui/completion/providers/ollama.py +320 -0
- meltygui/completion/providers/profiles.py +23 -0
- meltygui/core/README.md +88 -0
- meltygui/core/__init__.py +1 -0
- meltygui/core/automation/__init__.py +1 -0
- meltygui/core/automation/action_core.py +153 -0
- meltygui/core/automation/collection_action.py +45 -0
- meltygui/core/automation/mcp_eval.py +167 -0
- meltygui/core/automation/mcp_hotswap.py +107 -0
- meltygui/core/automation/mcp_query.py +552 -0
- meltygui/core/automation/mcp_server.py +657 -0
- meltygui/core/automation/orchestration_core.py +2636 -0
- meltygui/core/automation/query_core.py +124 -0
- meltygui/core/automation/search_core.py +26 -0
- meltygui/core/automation/selector_core.py +340 -0
- meltygui/core/automation/value_core.py +1752 -0
- meltygui/core/cache/__init__.py +1 -0
- meltygui/core/cache/cache_diagnostics.py +0 -0
- meltygui/core/cache/invalidation_decoration.py +153 -0
- meltygui/core/cache/invalidation_tracker.py +43 -0
- meltygui/core/cache/tile_cache.py +5968 -0
- meltygui/core/conversion/__init__.py +1 -0
- meltygui/core/conversion/bubbling.py +599 -0
- meltygui/core/conversion/cache_tree.py +188 -0
- meltygui/core/conversion/chain.py +113 -0
- meltygui/core/conversion/converter_register.py +145 -0
- meltygui/core/conversion/data_decoration.py +67 -0
- meltygui/core/conversion/dict_conversion.py +1815 -0
- meltygui/core/conversion/dict_conversion_util.py +177 -0
- meltygui/core/conversion/dynamic_obj.py +89 -0
- meltygui/core/conversion/graph_compare.py +183 -0
- meltygui/core/conversion/load_save_v2.py +1032 -0
- meltygui/core/conversion/missing_saved_class.py +39 -0
- meltygui/core/conversion/path_finder.py +606 -0
- meltygui/core/conversion/render_host.py +1083 -0
- meltygui/core/core_render.py +6537 -0
- meltygui/core/definition_hotswap.py +231 -0
- meltygui/core/diagnostics/__init__.py +1 -0
- meltygui/core/diagnostics/attribute_churn.py +38 -0
- meltygui/core/diagnostics/fps_counter.py +39 -0
- meltygui/core/diagnostics/gpu_frame_timer.py +103 -0
- meltygui/core/diagnostics/inspection_core.py +169 -0
- meltygui/core/diagnostics/monitor_core.py +105 -0
- meltygui/core/diagnostics/notifications.py +706 -0
- meltygui/core/diagnostics/perf_trace.py +281 -0
- meltygui/core/diagnostics/profile_decoration.py +88 -0
- meltygui/core/diagnostics/resize_trace.py +62 -0
- meltygui/core/diagnostics/screenshot_core.py +247 -0
- meltygui/core/diagnostics/session_status.py +98 -0
- meltygui/core/diagnostics/trace_core.py +445 -0
- meltygui/core/files/__init__.py +1 -0
- meltygui/core/files/file_core.py +208 -0
- meltygui/core/files/file_explorer_core.py +104 -0
- meltygui/core/files/file_tree_core.py +198 -0
- meltygui/core/files/file_watch_core.py +43 -0
- meltygui/core/files/import_graph_core.py +43 -0
- meltygui/core/files/metadata_core.py +51 -0
- meltygui/core/graphics/__init__.py +1 -0
- meltygui/core/graphics/cuda_context_core.py +166 -0
- meltygui/core/graphics/cuda_interop_core.py +136 -0
- meltygui/core/graphics/cuda_kernel_core.py +91 -0
- meltygui/core/graphics/framebuffer_recorder.py +337 -0
- meltygui/core/graphics/gl_state.py +658 -0
- meltygui/core/graphics/lut_core.py +52 -0
- meltygui/core/graphics/overlay_renderer.py +984 -0
- meltygui/core/graphics/scene_target.py +180 -0
- meltygui/core/graphics/screenshot.py +439 -0
- meltygui/core/graphics/shader_func.py +478 -0
- meltygui/core/graphics/tensor_core.py +45 -0
- meltygui/core/graphics/text_texture.py +329 -0
- meltygui/core/graphics/wayland_color.py +635 -0
- meltygui/core/input/__init__.py +1 -0
- meltygui/core/input/collision.py +165 -0
- meltygui/core/input/drag_drop_core.py +1525 -0
- meltygui/core/input/hypr_left_drag.py +323 -0
- meltygui/core/input/input_core.py +245 -0
- meltygui/core/input/input_handler.py +1101 -0
- meltygui/core/input/mouse_cursor.py +355 -0
- meltygui/core/input/pynput_backend.py +1054 -0
- meltygui/core/input/space_mouse.py +338 -0
- meltygui/core/input/touchpad_backend.py +393 -0
- meltygui/core/input/view_selection.py +177 -0
- meltygui/core/layout/__init__.py +1 -0
- meltygui/core/layout/column_core.py +2153 -0
- meltygui/core/layout/cursor_core.py +161 -0
- meltygui/core/layout/dropdown_core.py +278 -0
- meltygui/core/layout/edge_constraints.py +155 -0
- meltygui/core/layout/grid_core.py +117 -0
- meltygui/core/layout/header_core.py +23 -0
- meltygui/core/layout/header_runtime.py +67 -0
- meltygui/core/layout/layout_core.py +87 -0
- meltygui/core/layout/tile_manager_core.py +591 -0
- meltygui/core/melty.py +6726 -0
- meltygui/core/module_map.json +896 -0
- meltygui/core/module_names.py +19 -0
- meltygui/core/rendering/__init__.py +1 -0
- meltygui/core/rendering/core_decoration.py +431 -0
- meltygui/core/rendering/core_render_helpers.py +328 -0
- meltygui/core/rendering/func_metadata.py +398 -0
- meltygui/core/rendering/mode.py +818 -0
- meltygui/core/rendering/mode_defaults.py +41 -0
- meltygui/core/rendering/modes.py +136 -0
- meltygui/core/rendering/parameter_core.py +1665 -0
- meltygui/core/rendering/render_dispatch.py +1891 -0
- meltygui/core/rendering/render_funcs.py +273 -0
- meltygui/core/rendering/shaped.py +312 -0
- meltygui/core/rendering/window_decoration.py +25 -0
- meltygui/core/runtime/__init__.py +1 -0
- meltygui/core/runtime/app.py +735 -0
- meltygui/core/runtime/app_session.py +140 -0
- meltygui/core/runtime/background.py +564 -0
- meltygui/core/runtime/extensions.py +53 -0
- meltygui/core/runtime/gc_manager.py +1163 -0
- meltygui/core/runtime/lifecycle.py +21 -0
- meltygui/core/runtime/paths.py +27 -0
- meltygui/core/runtime/settings.py +14 -0
- meltygui/core/runtime/singleton.py +16 -0
- meltygui/core/runtime/thread_safe_bool.py +24 -0
- meltygui/core/runtime/thread_signal.py +30 -0
- meltygui/core/runtime/toggles.py +3115 -0
- meltygui/core/services/__init__.py +1 -0
- meltygui/core/services/account_core.py +10 -0
- meltygui/core/services/chat_core.py +19 -0
- meltygui/core/services/claude_terminal_core.py +346 -0
- meltygui/core/services/terminal_core.py +458 -0
- meltygui/core/services/terminal_runtime.py +78 -0
- meltygui/core/styling/__init__.py +1 -0
- meltygui/core/styling/color_core.py +46 -0
- meltygui/core/styling/fonts.py +639 -0
- meltygui/core/styling/global_style.py +338 -0
- meltygui/core/styling/style.py +198 -0
- meltygui/core/styling/style_core.py +522 -0
- meltygui/core/styling/warm_start.py +149 -0
- meltygui/core/windowing/__init__.py +1 -0
- meltygui/core/windowing/backends/PYIMGUI_LICENSE +28 -0
- meltygui/core/windowing/backends/__init__.py +1 -0
- meltygui/core/windowing/backends/imgui_renderer.py +138 -0
- meltygui/core/windowing/backends/native_wayland.py +850 -0
- meltygui/core/windowing/backends/protocols/xdg-decoration-unstable-v1.xml +156 -0
- meltygui/core/windowing/backends/protocols/xdg-shell.xml +1420 -0
- meltygui/core/windowing/backends/wayland_protocol.py +101 -0
- meltygui/core/windowing/dock_core.py +182 -0
- meltygui/core/windowing/frame_geometry.py +42 -0
- meltygui/core/windowing/geometry_feed.py +864 -0
- meltygui/core/windowing/glfw_utils.py +1343 -0
- meltygui/core/windowing/os_frame.py +1552 -0
- meltygui/core/windowing/surface.py +643 -0
- meltygui/core/windowing/titlebar.py +1560 -0
- meltygui/core/windowing/titlebar_buttons.py +281 -0
- meltygui/core/windowing/wayland_move.py +932 -0
- meltygui/core/windowing/window_api.py +62 -0
- meltygui/core/windowing/window_constants.py +339 -0
- meltygui/core/windowing/window_visibility.py +162 -0
- meltygui/debug/__init__.py +0 -0
- meltygui/debug/app_view_utils.py +9 -0
- meltygui/editor/__init__.py +0 -0
- meltygui/editor/bash_syntax.py +30 -0
- meltygui/editor/code_line_fast.py +152 -0
- meltygui/editor/diff.py +139 -0
- meltygui/editor/external_changes.py +159 -0
- meltygui/editor/file_header.py +41 -0
- meltygui/editor/live_usage.py +169 -0
- meltygui/editor/live_view_views.py +1803 -0
- meltygui/editor/pending_save.py +1351 -0
- meltygui/editor/roster_tints.py +547 -0
- meltygui/editor/source_preview.py +16 -0
- meltygui/editor/source_tools.py +10 -0
- meltygui/editor/source_ui.py +70 -0
- meltygui/editor/spell_check.py +77 -0
- meltygui/editor/text_editor.py +9076 -0
- meltygui/editor/usage_picker.py +538 -0
- meltygui/events/__init__.py +0 -0
- meltygui/events/example.py +118 -0
- meltygui/examples/__init__.py +0 -0
- meltygui/examples/columns_demo.py +33 -0
- meltygui/examples/columns_window_demo.py +60 -0
- meltygui/examples/context_menu_demo.py +24 -0
- meltygui/examples/context_menu_window_demo.py +49 -0
- meltygui/examples/gui_playground.py +127 -0
- meltygui/examples/live_view_playground.py +256 -0
- meltygui/examples/lora.py +43 -0
- meltygui/examples/lora_data.py +59 -0
- meltygui/examples/lora_policies.py +67 -0
- meltygui/examples/mode_demo.py +49 -0
- meltygui/examples/modifies_demo.py +119 -0
- meltygui/examples/scalar_policies.py +60 -0
- meltygui/examples/style_layouts.py +149 -0
- meltygui/examples/tile_manager_demo.py +74 -0
- meltygui/examples/tint_demo.py +160 -0
- meltygui/examples/tint_functions.py +56 -0
- meltygui/examples/trace_demo.py +88 -0
- meltygui/examples/two_windows.py +36 -0
- meltygui/files/__init__.py +0 -0
- meltygui/files/fast_file_explorer.py +439 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/extension.js +237 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/lsd-window-geometry@latent-descent.iml +9 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/metadata.json +7 -0
- meltygui/graphics/__init__.py +6 -0
- meltygui/graphics/base.py +85 -0
- meltygui/graphics/examples.py +507 -0
- meltygui/graphics/executor.py +520 -0
- meltygui/graphics/filter.py +667 -0
- meltygui/graphics/filter.pyi +856 -0
- meltygui/graphics/generate_stubs.py +22 -0
- meltygui/graphics/registry.py +203 -0
- meltygui/graphics/shader_compiler.py +155 -0
- meltygui/graphics/shaders.py +1302 -0
- meltygui/graphics/stub_generator.py +250 -0
- meltygui/graphics/texture_manager.py +170 -0
- meltygui/graphics/texture_min_max.py +295 -0
- meltygui/hdr_color.py +757 -0
- meltygui/image_load.py +308 -0
- meltygui/model/__init__.py +1 -0
- meltygui/model/account_model.py +142 -0
- meltygui/model/camera_model.py +159 -0
- meltygui/model/chat_model.py +86 -0
- meltygui/model/code_model.py +62 -0
- meltygui/model/code_proxy_model.py +876 -0
- meltygui/model/collection_model.py +41 -0
- meltygui/model/color_model.py +127 -0
- meltygui/model/cuda_tensor_model.py +36 -0
- meltygui/model/cuda_texture_model.py +149 -0
- meltygui/model/dropdown_model.py +137 -0
- meltygui/model/file_metadata_model.py +73 -0
- meltygui/model/file_model.py +289 -0
- meltygui/model/format_model.py +390 -0
- meltygui/model/graph_model.py +98 -0
- meltygui/model/icon_model.py +1024 -0
- meltygui/model/import_graph_model.py +468 -0
- meltygui/model/layout_model.py +15 -0
- meltygui/model/lut_model.py +266 -0
- meltygui/model/search_model.py +173 -0
- meltygui/model/tensor_model.py +402 -0
- meltygui/model/terminal_model.py +329 -0
- meltygui/model/texture_model.py +146 -0
- meltygui/model/tile_model.py +24 -0
- meltygui/model/trace_model.py +198 -0
- meltygui/model/trace_report_model.py +146 -0
- meltygui/models/__init__.py +0 -0
- meltygui/models/file_meta.py +613 -0
- meltygui/models/function_console.py +184 -0
- meltygui/models/orchestration.py +48 -0
- meltygui/pbr.py +1576 -0
- meltygui/png_unfilter.c +49 -0
- meltygui/resources/JetBrainsMono-Regular.ttf +0 -0
- meltygui/resources/THIRD_PARTY_NOTICES.md +21 -0
- meltygui/resources/dejavu/DejaVuSans-Bold.ttf +0 -0
- meltygui/resources/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
- meltygui/resources/dejavu/DejaVuSans.ttf +0 -0
- meltygui/resources/dejavu/LICENSE.txt +78 -0
- meltygui/resources/fontawesome-LICENSE.txt +121 -0
- meltygui/resources/fontawesome-webfont.ttf +0 -0
- meltygui/resources/hdri/studio_small_09_1k.hdr +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Bold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraBold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraLight.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Light.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Medium.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-SemiBold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Thin.ttf +0 -0
- meltygui/resources/jetbrains-weights/OFL.txt +93 -0
- meltygui/resources/jetbrains-weights/README.md +2 -0
- meltygui/state/__init__.py +0 -0
- meltygui/state/account_state.py +22 -0
- meltygui/state/animation_state.py +97 -0
- meltygui/state/annotation_state.py +22 -0
- meltygui/state/chat_state.py +36 -0
- meltygui/state/code_state.py +10 -0
- meltygui/state/core_enums.py +59 -0
- meltygui/state/core_markers.py +115 -0
- meltygui/state/core_undo.py +1075 -0
- meltygui/state/file_state.py +75 -0
- meltygui/state/graph_state.py +26 -0
- meltygui/state/inspection_state.py +47 -0
- meltygui/state/menu_state.py +10 -0
- meltygui/state/model_enums.py +11 -0
- meltygui/state/new_core_model.py +2394 -0
- meltygui/state/orchestration_state.py +14 -0
- meltygui/state/query_state.py +24 -0
- meltygui/state/tensor_state.py +10 -0
- meltygui/state/terminal_state.py +23 -0
- meltygui/state/trace_state.py +32 -0
- meltygui/state/voxel_state.py +12 -0
- meltygui/text_index.py +816 -0
- meltygui/utils/__init__.py +0 -0
- meltygui/utils/jump_to_code.py +344 -0
- meltygui/utils/pkl_inspect.py +90 -0
- meltygui/utils/render_utils.py +1419 -0
- meltygui/view/__init__.py +1 -0
- meltygui/view/account_view.py +524 -0
- meltygui/view/action_view.py +73 -0
- meltygui/view/chat_decoration_view.py +112 -0
- meltygui/view/chat_view.py +1703 -0
- meltygui/view/code_view.py +2677 -0
- meltygui/view/collection_view.py +1185 -0
- meltygui/view/color_view.py +824 -0
- meltygui/view/control_view.py +559 -0
- meltygui/view/decoration_view.py +439 -0
- meltygui/view/diagnostic_view.py +177 -0
- meltygui/view/dropdown_view.py +1082 -0
- meltygui/view/file_view.py +1599 -0
- meltygui/view/graph_cuda_view.py +105 -0
- meltygui/view/graph_view.py +882 -0
- meltygui/view/header_view.py +848 -0
- meltygui/view/input_view.py +238 -0
- meltygui/view/inspection_view.py +1783 -0
- meltygui/view/layout_view.py +596 -0
- meltygui/view/lut_view.py +45 -0
- meltygui/view/menu_view.py +212 -0
- meltygui/view/orchestration_view.py +658 -0
- meltygui/view/query_view.py +118 -0
- meltygui/view/search_view.py +342 -0
- meltygui/view/tab_view.py +258 -0
- meltygui/view/tensor_view.py +430 -0
- meltygui/view/terminal_view.py +355 -0
- meltygui/view/text_view.py +8031 -0
- meltygui/view/texture_view.py +480 -0
- meltygui/view/tile_view.py +38 -0
- meltygui/view/trace_view.py +923 -0
- meltygui/view/voxel_cuda_view.py +824 -0
- meltygui/view/voxel_view.py +1860 -0
- meltygui/view/window_view.py +111 -0
- meltygui-0.1.0.dist-info/METADATA +143 -0
- meltygui-0.1.0.dist-info/RECORD +372 -0
- meltygui-0.1.0.dist-info/WHEEL +4 -0
- meltygui-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,1163 @@
|
|
|
1
|
+
"""Deliberate garbage-collection scheduling for the studio.
|
|
2
|
+
|
|
3
|
+
The stock collector runs generation-2 passes at arbitrary allocation points —
|
|
4
|
+
observed as a 3.3s GIL-held stall (613k objects collected) landing on the
|
|
5
|
+
render thread mid-typing, with several large cst-dict graphs resident. Three
|
|
6
|
+
measures, all Toggles.GC-gated, applied from the per-frame tick() (hooked in
|
|
7
|
+
Melty.end_frame):
|
|
8
|
+
|
|
9
|
+
* thresholds — gen2's auto-trigger is pushed effectively out of reach
|
|
10
|
+
(gen0/gen1 stay stock: young-object passes are cheap), so full
|
|
11
|
+
collections only happen when WE schedule them;
|
|
12
|
+
* boot freeze — at the first input-idle window after boot, one full
|
|
13
|
+
collect then gc.freeze(): the stable app graph (modules, fonts, studio,
|
|
14
|
+
parse caches) moves to the permanent generation and is never walked
|
|
15
|
+
again. Cycles alive at freeze time are leaked by design — app-lifetime
|
|
16
|
+
state doesn't care;
|
|
17
|
+
* idle collects — while input stays quiet, a periodic gc.collect() drains
|
|
18
|
+
the cyclic garbage editing accumulates. Post-freeze the pass only walks
|
|
19
|
+
objects allocated since, so it is small — and it lands when nobody is
|
|
20
|
+
typing.
|
|
21
|
+
|
|
22
|
+
Every pass reports through the "lag" notify column: its duration, how many
|
|
23
|
+
objects (and roughly how many bytes) went, and the top types — and the toast
|
|
24
|
+
is clickable: it opens that collect's REPORT (one file per collect under
|
|
25
|
+
Toggles.GC.report_dir: type / module / dict-key-signature / function / frame
|
|
26
|
+
histograms of the reclaimed cycles, sample reprs, and the scheduler's reason)
|
|
27
|
+
in the code editor. Toggles.memory_profile additionally histograms, for the
|
|
28
|
+
boot pass, the whole live graph about to be frozen, appended to
|
|
29
|
+
/tmp/lsd_gc_profile.log. State survives hotswap via the globals().get pattern; the
|
|
30
|
+
end_frame hook line in meltygui.py is restart-bound (meltygui never hotswaps).
|
|
31
|
+
"""
|
|
32
|
+
import gc
|
|
33
|
+
import time
|
|
34
|
+
|
|
35
|
+
from meltygui.core.runtime.paths import debug_log_path
|
|
36
|
+
from meltygui.core.diagnostics.notifications import notify
|
|
37
|
+
from meltygui.core.diagnostics.notifications import capture_stack
|
|
38
|
+
from meltygui.core.runtime.toggles import Toggles
|
|
39
|
+
|
|
40
|
+
_state = globals().get("_state") or {
|
|
41
|
+
"applied": False, # thresholds currently overridden
|
|
42
|
+
"frozen": False, # boot collect+freeze done
|
|
43
|
+
"last_collect": 0.0,
|
|
44
|
+
"boot_t": time.monotonic(),
|
|
45
|
+
"last_tick": 0.0, # frame gap detection (frames park in wait_events)
|
|
46
|
+
"focused": True, # glfw FOCUSED as of the last tick
|
|
47
|
+
"resumed_t": 0.0, # last focus-gain / frame-gap moment
|
|
48
|
+
"unfocus_armed_t": 0.0, # focus-LOSS edge seen; not once it is confirmed
|
|
49
|
+
}
|
|
50
|
+
# Hotswap reuses the live _state dict - backfill fields added since.
|
|
51
|
+
for _k, _v in (("last_tick", 0.0), ("focused", True), ("resumed_t", 0.0),
|
|
52
|
+
("unfocus_armed_t", 0.0)):
|
|
53
|
+
_state.setdefault(_k, _v)
|
|
54
|
+
|
|
55
|
+
PROFILE_LOG = debug_log_path("lsd_gc_profile.log")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _profile_enabled():
|
|
59
|
+
from meltygui.core.runtime.toggles import Toggles
|
|
60
|
+
return bool(Toggles.memory_profile)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _type_name(o):
|
|
64
|
+
t = type(o)
|
|
65
|
+
mod = getattr(t, "__module__", "") or ""
|
|
66
|
+
return f"{mod}.{t.__qualname__}" if mod not in ("builtins", "") else t.__qualname__
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _histogram(objs, top=30):
|
|
70
|
+
from collections import Counter
|
|
71
|
+
c = Counter()
|
|
72
|
+
for o in objs:
|
|
73
|
+
c[_type_name(o)] += 1
|
|
74
|
+
return c.most_common(top), sum(c.values())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _samples(objs, wanted, per_type=3, width=160):
|
|
78
|
+
"""A few truncated reprs per type so 'dict' / 'list' rows say WHICH dicts.
|
|
79
|
+
Plain containers get their key/element summary instead of repr()."""
|
|
80
|
+
out = {}
|
|
81
|
+
for o in objs:
|
|
82
|
+
tn = _type_name(o)
|
|
83
|
+
if tn not in wanted:
|
|
84
|
+
continue
|
|
85
|
+
got = out.setdefault(tn, [])
|
|
86
|
+
if len(got) >= per_type:
|
|
87
|
+
continue
|
|
88
|
+
try:
|
|
89
|
+
if type(o) is dict:
|
|
90
|
+
r = "dict keys=" + repr(list(o.keys())[:8])
|
|
91
|
+
elif type(o) in (list, tuple, set):
|
|
92
|
+
r = f"{tn}[{len(o)}] " + repr(o[:4] if type(o) is not set else list(o)[:4])
|
|
93
|
+
elif hasattr(o, "shape") and hasattr(o, "dtype"):
|
|
94
|
+
# A tensor / ndarray repr materializes (and allocs) the data.
|
|
95
|
+
r = f"{tn} shape={tuple(o.shape)} dtype={o.dtype}"
|
|
96
|
+
else:
|
|
97
|
+
r = repr(o)
|
|
98
|
+
if " object at 0x" in r and hasattr(o, "__dict__"):
|
|
99
|
+
r += " attrs=" + repr(list(vars(o).keys())[:10])
|
|
100
|
+
except Exception as e:
|
|
101
|
+
r = f"<repr failed: {e!r}>"
|
|
102
|
+
got.append(r.replace("\n", " ")[:width])
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _write(lines):
|
|
107
|
+
try:
|
|
108
|
+
with open(PROFILE_LOG, "a") as f:
|
|
109
|
+
f.write("\n".join(lines) + "\n")
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _report_dir():
|
|
115
|
+
"""Where per-collect reports go — `Toggles.GC.report_dir` (expanded),
|
|
116
|
+
falling back to the in-repo `.melty/gc_reports` (the screenshot
|
|
117
|
+
convention). Created on demand; None if neither is writable."""
|
|
118
|
+
import os
|
|
119
|
+
from pathlib import Path
|
|
120
|
+
from meltygui.core.runtime.toggles import Toggles
|
|
121
|
+
candidates = []
|
|
122
|
+
configured = Toggles.GC.report_dir
|
|
123
|
+
if configured:
|
|
124
|
+
candidates.append(Path(configured).expanduser())
|
|
125
|
+
from meltygui.core.runtime.paths import cache_root
|
|
126
|
+
candidates.append(cache_root() / "gc_reports")
|
|
127
|
+
for d in candidates:
|
|
128
|
+
try:
|
|
129
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
if os.access(d, os.W_OK):
|
|
131
|
+
return d
|
|
132
|
+
except Exception:
|
|
133
|
+
continue
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _write_report(label, lines):
|
|
138
|
+
"""One file per collect (`gc_<HHMMSS>_<label>.txt`), oldest pruned past
|
|
139
|
+
`Toggles.GC.report_keep`. Returns the path, or None."""
|
|
140
|
+
import re
|
|
141
|
+
from meltygui.core.runtime.toggles import Toggles
|
|
142
|
+
d = _report_dir()
|
|
143
|
+
if d is None:
|
|
144
|
+
return None
|
|
145
|
+
slug = re.sub(r"[^A-Za-z0-9]+", "_", label).strip("_") or "collect"
|
|
146
|
+
path = d / f"gc_{time.strftime('%Y%m%d_%H%M%S')}_{slug}.txt"
|
|
147
|
+
try:
|
|
148
|
+
path.write_text("\n".join(lines) + "\n")
|
|
149
|
+
keep = int(Toggles.GC.report_keep or 0)
|
|
150
|
+
if keep > 0:
|
|
151
|
+
old = sorted(d.glob("gc_*.txt"))[:-keep]
|
|
152
|
+
for f in old:
|
|
153
|
+
f.unlink(missing_ok=True)
|
|
154
|
+
except Exception:
|
|
155
|
+
return None
|
|
156
|
+
return path
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _detail_rows(objs, top=15):
|
|
160
|
+
"""WHAT the reclaimed objects were, one level below the type histogram:
|
|
161
|
+
dicts grouped by their key signature (which dicts), functions by
|
|
162
|
+
qualname, frames / code / cells by the code they belong to, methods by
|
|
163
|
+
their function. The type histogram says "6,000 dicts"; these rows say
|
|
164
|
+
"4,100 of them are draw_state kwargs dicts"."""
|
|
165
|
+
import types
|
|
166
|
+
from collections import Counter
|
|
167
|
+
dicts, funcs, frames, cells, methods, code_objs = (Counter() for _ in range(6))
|
|
168
|
+
for o in objs:
|
|
169
|
+
t = type(o)
|
|
170
|
+
try:
|
|
171
|
+
if t is dict:
|
|
172
|
+
keys = list(o.keys())
|
|
173
|
+
sig = ", ".join(str(k)[:24] for k in keys[:5])
|
|
174
|
+
if len(keys) > 5:
|
|
175
|
+
sig += f", … (+{len(keys) - 5})"
|
|
176
|
+
dicts[f"{{{sig}}}"] += 1
|
|
177
|
+
elif t is types.FunctionType:
|
|
178
|
+
funcs[f"{o.__module__}.{o.__qualname__}"] += 1
|
|
179
|
+
elif t is types.FrameType:
|
|
180
|
+
c = o.f_code
|
|
181
|
+
frames[f"{c.co_name} {c.co_filename.rsplit('/', 1)[-1]}:{o.f_lineno}"] += 1
|
|
182
|
+
elif t is types.CellType:
|
|
183
|
+
cells[_type_name(o.cell_contents) if o.cell_contents is not None else "None"] += 1
|
|
184
|
+
elif t is types.MethodType:
|
|
185
|
+
f = o.__func__
|
|
186
|
+
methods[f"{getattr(f, '__module__', '?')}.{getattr(f, '__qualname__', '?')}"] += 1
|
|
187
|
+
elif t is types.CodeType:
|
|
188
|
+
code_objs[f"{o.co_name} {o.co_filename.rsplit('/', 1)[-1]}:{o.co_firstlineno}"] += 1
|
|
189
|
+
except Exception:
|
|
190
|
+
continue
|
|
191
|
+
lines = []
|
|
192
|
+
for title, counter in (("dicts by key signature", dicts),
|
|
193
|
+
("functions by qualname", funcs),
|
|
194
|
+
("bound methods by function", methods),
|
|
195
|
+
("frames by code site", frames),
|
|
196
|
+
("code objects", code_objs),
|
|
197
|
+
("cells by content type", cells)):
|
|
198
|
+
if not counter:
|
|
199
|
+
continue
|
|
200
|
+
lines.append(f"--- {title} (top {top} of {len(counter)} distinct):")
|
|
201
|
+
for name, n in counter.most_common(top):
|
|
202
|
+
lines.append(f" {n:>10} {name}")
|
|
203
|
+
return lines
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _module_histogram(objs, top=15):
|
|
207
|
+
"""The reclaimed objects by the MODULE their type was defined in — the
|
|
208
|
+
quickest "whose garbage is this" read (src.lsd… vs libcst vs torch)."""
|
|
209
|
+
from collections import Counter
|
|
210
|
+
c = Counter()
|
|
211
|
+
for o in objs:
|
|
212
|
+
c[getattr(type(o), "__module__", "") or "builtins"] += 1
|
|
213
|
+
return [f"--- by type's module (top {top}):"] + [
|
|
214
|
+
f" {n:>10} {m}" for m, n in c.most_common(top)]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _approx_bytes(objs):
|
|
218
|
+
"""sys.getsizeof over the reclaimed objects — shallow (no referents
|
|
219
|
+
that survive elsewhere), so a lower bound on what the collect freed."""
|
|
220
|
+
import sys
|
|
221
|
+
total = 0
|
|
222
|
+
for o in objs:
|
|
223
|
+
try:
|
|
224
|
+
total += sys.getsizeof(o)
|
|
225
|
+
except Exception:
|
|
226
|
+
pass
|
|
227
|
+
return total
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _fmt_bytes(n):
|
|
231
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
232
|
+
if n < 1024 or unit == "GB":
|
|
233
|
+
return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
|
|
234
|
+
n /= 1024.0
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _thread_report():
|
|
238
|
+
"""One line per thread: name + the innermost src.* frame's module, tagged
|
|
239
|
+
STALE when that module dict is no longer the one registered in
|
|
240
|
+
sys.modules — i.e. a thread left over from a previous in-process session,
|
|
241
|
+
pinning that session's whole graph (see lifecycle.module_is_live)."""
|
|
242
|
+
import sys
|
|
243
|
+
import threading
|
|
244
|
+
from meltygui.core.runtime.lifecycle import module_is_live
|
|
245
|
+
frames = sys._current_frames()
|
|
246
|
+
lines = ["--- THREADS (STALE = running in a purged prior-session module):"]
|
|
247
|
+
stale = 0
|
|
248
|
+
for th in threading.enumerate():
|
|
249
|
+
f = frames.get(th.ident)
|
|
250
|
+
where, tag = "?", ""
|
|
251
|
+
while f is not None:
|
|
252
|
+
g = f.f_globals
|
|
253
|
+
name = g.get("__name__", "")
|
|
254
|
+
if name.startswith("src."):
|
|
255
|
+
where = f"{name}:{f.f_code.co_name}:{f.f_lineno}"
|
|
256
|
+
if not module_is_live(g):
|
|
257
|
+
tag = " STALE"
|
|
258
|
+
stale += 1
|
|
259
|
+
break
|
|
260
|
+
f = f.f_back
|
|
261
|
+
lines.append(f" {th.name:<40} {where}{tag}")
|
|
262
|
+
lines.append(f" ({stale} stale of {len(lines) - 1})")
|
|
263
|
+
return lines
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _describe_root(o, world):
|
|
267
|
+
"""Short label for a live object that points into the stale world: what it
|
|
268
|
+
is, and (for dicts / module dicts / frames) WHICH slot does the pointing."""
|
|
269
|
+
import sys
|
|
270
|
+
import types
|
|
271
|
+
t = type(o)
|
|
272
|
+
if t is dict:
|
|
273
|
+
name = o.get("__name__") if "__file__" in o or "__spec__" in o else None
|
|
274
|
+
keys = [k for k, v in list(o.items())[:4000] if id(v) in world][:4]
|
|
275
|
+
if isinstance(name, str):
|
|
276
|
+
return f"module dict {name} keys={keys}"
|
|
277
|
+
return f"dict keys={keys} (of {len(o)})"
|
|
278
|
+
if t is types.FrameType:
|
|
279
|
+
return f"frame {o.f_globals.get('__name__')}:{o.f_code.co_name}"
|
|
280
|
+
if t is types.FunctionType:
|
|
281
|
+
return f"function {o.__module__}.{o.__qualname__}"
|
|
282
|
+
if t is types.MethodType:
|
|
283
|
+
return f"method {type(o.__self__).__name__}.{o.__func__.__qualname__}"
|
|
284
|
+
if t is types.CellType:
|
|
285
|
+
return "cell"
|
|
286
|
+
if t in (list, tuple, set):
|
|
287
|
+
return f"{t.__name__}[{len(o)}]"
|
|
288
|
+
return _type_name(o)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _stale_world_report(live):
|
|
292
|
+
"""Which purged src.* modules are still alive, how big the graph hanging
|
|
293
|
+
off them is, and — the actual answer — which LIVE objects point into it.
|
|
294
|
+
Seeds: module objects named src.* that sys.modules no longer maps to.
|
|
295
|
+
World: everything reachable from the seeds WITHOUT crossing into live
|
|
296
|
+
modules / live module dicts / the sys._* shared stores. Roots: objects
|
|
297
|
+
outside the world with a direct referent inside it."""
|
|
298
|
+
import sys
|
|
299
|
+
import types
|
|
300
|
+
from collections import Counter
|
|
301
|
+
# Seeds are module DICTS: the module object itself usually dies with the
|
|
302
|
+
# purge, but its module dict lives on in every function's __globals__.
|
|
303
|
+
live_mod_dicts = {id(vars(m)) for m in list(sys.modules.values())
|
|
304
|
+
if hasattr(m, "__dict__")}
|
|
305
|
+
|
|
306
|
+
def _stale_mod_name(d):
|
|
307
|
+
if type(d) is not dict or id(d) in live_mod_dicts or "__spec__" not in d:
|
|
308
|
+
return None
|
|
309
|
+
n = d.get("__name__")
|
|
310
|
+
return n if isinstance(n, str) and n.startswith("src.") else None
|
|
311
|
+
|
|
312
|
+
def _stale_type(t):
|
|
313
|
+
"""A class is stale when the module it names no longer binds it under
|
|
314
|
+
its qualname — the live class (even if reachable from old data via
|
|
315
|
+
shared stores) must NOT be crossed, or every live instance of it
|
|
316
|
+
reads as a root."""
|
|
317
|
+
modname = getattr(t, "__module__", None)
|
|
318
|
+
if not (isinstance(modname, str) and modname.startswith("src.")):
|
|
319
|
+
return False
|
|
320
|
+
obj = sys.modules.get(modname)
|
|
321
|
+
if obj is None:
|
|
322
|
+
return True
|
|
323
|
+
for part in t.__qualname__.split("."):
|
|
324
|
+
obj = getattr(obj, part, None)
|
|
325
|
+
if obj is None:
|
|
326
|
+
return True
|
|
327
|
+
return obj is not t
|
|
328
|
+
|
|
329
|
+
_NO_CROSS = (types.BuiltinFunctionType, types.MethodDescriptorType,
|
|
330
|
+
types.WrapperDescriptorType, types.GetSetDescriptorType,
|
|
331
|
+
types.MemberDescriptorType, types.ClassMethodDescriptorType)
|
|
332
|
+
|
|
333
|
+
def _crossable(r):
|
|
334
|
+
"""Follow into session-owned data only — never into process-shared
|
|
335
|
+
singletons (builtin/foreign classes, foreign functions, descriptors,
|
|
336
|
+
atoms), which would make every live object look like a 'root'."""
|
|
337
|
+
if not gc.is_tracked(r):
|
|
338
|
+
return False
|
|
339
|
+
t = type(r)
|
|
340
|
+
if t is types.ModuleType:
|
|
341
|
+
n = getattr(r, "__name__", None)
|
|
342
|
+
return isinstance(n, str) and n.startswith("src.") and sys.modules.get(n) is not r
|
|
343
|
+
if t is dict:
|
|
344
|
+
if "__spec__" in r and isinstance(r.get("__name__"), str):
|
|
345
|
+
return _stale_mod_name(r) is not None
|
|
346
|
+
return True
|
|
347
|
+
if isinstance(r, type):
|
|
348
|
+
return _stale_type(r)
|
|
349
|
+
if t is types.FunctionType:
|
|
350
|
+
return _stale_mod_name(r.__globals__) is not None
|
|
351
|
+
if t in _NO_CROSS:
|
|
352
|
+
return False
|
|
353
|
+
if t.__module__ == "ast" and t.__name__ in ("Load", "Store", "Del"):
|
|
354
|
+
return False # process-shared singletons: every live ast node points at them
|
|
355
|
+
return True
|
|
356
|
+
|
|
357
|
+
seeds = [o for o in live if _stale_mod_name(o)]
|
|
358
|
+
if not seeds:
|
|
359
|
+
return ["--- STALE MODULES: none alive"]
|
|
360
|
+
lines = [f"--- STALE MODULES (purged src.* module dicts still alive): {len(seeds)}"]
|
|
361
|
+
for name, n in Counter(d["__name__"] for d in seeds).most_common(12):
|
|
362
|
+
lines.append(f" {n:>4} {name}")
|
|
363
|
+
shared = set()
|
|
364
|
+
for k, v in list(vars(sys).items()):
|
|
365
|
+
if k.startswith("_"):
|
|
366
|
+
shared.add(id(v))
|
|
367
|
+
try:
|
|
368
|
+
shared.update(id(r) for r in gc.get_referents(v))
|
|
369
|
+
except Exception:
|
|
370
|
+
pass
|
|
371
|
+
skip = live_mod_dicts | shared | {id(live), id(seeds)}
|
|
372
|
+
world, stack = set(), list(seeds)
|
|
373
|
+
t0 = time.perf_counter()
|
|
374
|
+
while stack:
|
|
375
|
+
o = stack.pop()
|
|
376
|
+
if id(o) in world:
|
|
377
|
+
continue
|
|
378
|
+
world.add(id(o))
|
|
379
|
+
for r in gc.get_referents(o):
|
|
380
|
+
rid = id(r)
|
|
381
|
+
if rid in world or rid in skip or not _crossable(r):
|
|
382
|
+
continue
|
|
383
|
+
stack.append(r)
|
|
384
|
+
wc = Counter()
|
|
385
|
+
for o in live:
|
|
386
|
+
if id(o) in world:
|
|
387
|
+
wc[_type_name(o)] += 1
|
|
388
|
+
lines.append(f"--- STALE WORLD: {len(world)} objects reachable from them "
|
|
389
|
+
f"({1000*(time.perf_counter()-t0):.0f}ms); top types:")
|
|
390
|
+
for tn, n in wc.most_common(15):
|
|
391
|
+
lines.append(f" {n:>9} {tn}")
|
|
392
|
+
t0 = time.perf_counter()
|
|
393
|
+
roots = Counter()
|
|
394
|
+
examples = {}
|
|
395
|
+
me = globals()
|
|
396
|
+
for o in live:
|
|
397
|
+
oid = id(o)
|
|
398
|
+
if oid in world or o is live or o is seeds:
|
|
399
|
+
continue
|
|
400
|
+
if type(o) is types.FrameType and o.f_globals is me:
|
|
401
|
+
continue
|
|
402
|
+
if type(o) in _NO_CROSS:
|
|
403
|
+
continue # slot/getset descriptors of the old classes: not holders
|
|
404
|
+
try:
|
|
405
|
+
refs = gc.get_referents(o)
|
|
406
|
+
except Exception:
|
|
407
|
+
continue
|
|
408
|
+
if any(id(r) in world for r in refs):
|
|
409
|
+
try:
|
|
410
|
+
d = _describe_root(o, world)
|
|
411
|
+
except Exception as e:
|
|
412
|
+
d = f"{_type_name(o)} <describe failed {e!r}>"
|
|
413
|
+
roots[d] += 1
|
|
414
|
+
examples.setdefault(d, o)
|
|
415
|
+
lines.append(f"--- ROOTS (live objects pointing INTO the stale world): "
|
|
416
|
+
f"{sum(roots.values())} ({1000*(time.perf_counter()-t0):.0f}ms)")
|
|
417
|
+
# Whose dict is it? One get_referrers per top dict root (each is a full
|
|
418
|
+
# heap scan, so capped): find instance/class whose __dict__ is that dict.
|
|
419
|
+
owners = {}
|
|
420
|
+
budget = 8
|
|
421
|
+
for d, n in roots.most_common(40):
|
|
422
|
+
ex = examples.get(d)
|
|
423
|
+
if budget <= 0 or type(ex) is not dict or d.startswith("module dict"):
|
|
424
|
+
continue
|
|
425
|
+
budget -= 1
|
|
426
|
+
try:
|
|
427
|
+
for r in gc.get_referrers(ex):
|
|
428
|
+
if r is live or r is seeds:
|
|
429
|
+
continue
|
|
430
|
+
if getattr(r, "__dict__", None) is ex:
|
|
431
|
+
owners[d] = f"{_type_name(r)}" if not isinstance(r, type) \
|
|
432
|
+
else f"class {r.__module__}.{r.__qualname__}"
|
|
433
|
+
break
|
|
434
|
+
if type(r) is dict:
|
|
435
|
+
ks = [k for k, v in list(r.items())[:2000] if v is ex][:2]
|
|
436
|
+
owners[d] = f"nested under dict keys={ks}"
|
|
437
|
+
except Exception:
|
|
438
|
+
pass
|
|
439
|
+
for d, n in roots.most_common(40):
|
|
440
|
+
own = owners.get(d)
|
|
441
|
+
lines.append(f" {n:>6} {d[:150]}" + (f" <- {own}" if own else ""))
|
|
442
|
+
# Upward anchor chains for the top roots: who holds the holder, up to a
|
|
443
|
+
# named anchor (module global / sys attribute / class attribute / thread
|
|
444
|
+
# frame). Each hop is a full-heap get_referrers, so this is time-budgeted.
|
|
445
|
+
lines.append("--- ANCHOR CHAINS (top roots, upward; budgeted):")
|
|
446
|
+
deadline = time.perf_counter() + 10.0
|
|
447
|
+
sys_attrs = {id(v): k for k, v in list(vars(sys).items())}
|
|
448
|
+
mod_dict_names = {id(vars(m)): n for n, m in list(sys.modules.items())
|
|
449
|
+
if hasattr(m, "__dict__")}
|
|
450
|
+
own_structs = {id(live), id(seeds), id(roots), id(examples), id(owners)}
|
|
451
|
+
chased = 0
|
|
452
|
+
for d, n in roots.most_common(12):
|
|
453
|
+
if chased >= 4 or time.perf_counter() > deadline:
|
|
454
|
+
break
|
|
455
|
+
ex = examples.get(d)
|
|
456
|
+
if ex is None or d.startswith("module dict"):
|
|
457
|
+
continue
|
|
458
|
+
chased += 1
|
|
459
|
+
chain = [f"{d[:90]} (x{n})"]
|
|
460
|
+
cur = ex
|
|
461
|
+
visited = {id(cur)}
|
|
462
|
+
for _hop in range(6):
|
|
463
|
+
if time.perf_counter() > deadline:
|
|
464
|
+
chain.append("… (time budget)")
|
|
465
|
+
break
|
|
466
|
+
if id(cur) in sys_attrs:
|
|
467
|
+
chain.append(f"sys.{sys_attrs[id(cur)]}")
|
|
468
|
+
break
|
|
469
|
+
if id(cur) in mod_dict_names:
|
|
470
|
+
chain.append(f"module {mod_dict_names[id(cur)]} globals")
|
|
471
|
+
break
|
|
472
|
+
try:
|
|
473
|
+
refs = [r for r in gc.get_referrers(cur)
|
|
474
|
+
if id(r) not in own_structs and id(r) not in visited
|
|
475
|
+
and id(r) not in world
|
|
476
|
+
and not (type(r) is types.FrameType and r.f_globals is me)]
|
|
477
|
+
except Exception:
|
|
478
|
+
break
|
|
479
|
+
if not refs:
|
|
480
|
+
chain.append("(no live referrer — held only from inside the stale world)")
|
|
481
|
+
break
|
|
482
|
+
# Prefer the most "anchored" referrer: frames, module dicts, sys
|
|
483
|
+
# values, classes first, plain containers after.
|
|
484
|
+
def _rank(r):
|
|
485
|
+
if type(r) is types.FrameType: return 0
|
|
486
|
+
if id(r) in mod_dict_names or id(r) in sys_attrs: return 0
|
|
487
|
+
if isinstance(r, type): return 1
|
|
488
|
+
if type(r) is dict: return 2
|
|
489
|
+
return 3
|
|
490
|
+
refs.sort(key=_rank)
|
|
491
|
+
nxt = refs[0]
|
|
492
|
+
visited.add(id(nxt))
|
|
493
|
+
if type(nxt) is dict:
|
|
494
|
+
ks = [k for k, v in list(nxt.items())[:4000] if v is cur][:2]
|
|
495
|
+
if id(nxt) in mod_dict_names:
|
|
496
|
+
chain.append(f"module {mod_dict_names[id(nxt)]} globals {ks}")
|
|
497
|
+
break
|
|
498
|
+
chain.append(f"dict[{ks}] (of {len(nxt)})")
|
|
499
|
+
elif type(nxt) is types.FrameType:
|
|
500
|
+
chain.append(f"frame {nxt.f_globals.get('__name__')}:{nxt.f_code.co_name}:{nxt.f_lineno}")
|
|
501
|
+
break
|
|
502
|
+
elif isinstance(nxt, type):
|
|
503
|
+
slot = [k for k, v in vars(nxt).items() if v is cur][:2]
|
|
504
|
+
chain.append(f"class {nxt.__module__}.{nxt.__qualname__} attrs={slot}")
|
|
505
|
+
break
|
|
506
|
+
elif type(nxt) in (list, tuple, set, frozenset):
|
|
507
|
+
chain.append(f"{type(nxt).__name__}[{len(nxt)}]")
|
|
508
|
+
elif type(nxt) is types.CellType:
|
|
509
|
+
chain.append("cell")
|
|
510
|
+
elif type(nxt) is types.FunctionType:
|
|
511
|
+
chain.append(f"function {nxt.__module__}.{nxt.__qualname__}")
|
|
512
|
+
elif type(nxt) is types.MethodType:
|
|
513
|
+
chain.append(f"bound method {type(nxt.__self__).__name__}.{nxt.__func__.__qualname__}")
|
|
514
|
+
else:
|
|
515
|
+
slot = []
|
|
516
|
+
try:
|
|
517
|
+
slot = [k for k, v in vars(nxt).items() if v is cur][:2]
|
|
518
|
+
except Exception:
|
|
519
|
+
pass
|
|
520
|
+
chain.append(f"{_type_name(nxt)} attrs={slot}")
|
|
521
|
+
cur = nxt
|
|
522
|
+
lines.append(" " + " -> ".join(chain))
|
|
523
|
+
return lines
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _collect(label, live_graph=False, reason=""):
|
|
527
|
+
"""gc.collect() plus a REPORT of what it reclaimed — written to one
|
|
528
|
+
file per collect (`_write_report`, under Toggles.GC.report_dir) and
|
|
529
|
+
summarized in a "lag"-column toast whose click opens that file in the
|
|
530
|
+
code editor (the screenshot toast's convention). DEBUG_SAVEALL parks
|
|
531
|
+
every reclaimed cyclic object in gc.garbage so it can be histogrammed by
|
|
532
|
+
type (with a few reprs per top type), grouped one level finer
|
|
533
|
+
(`_detail_rows`: which dicts, which functions, which frames) and by
|
|
534
|
+
module, then released. `reason` is the scheduler's one-line "why now".
|
|
535
|
+
|
|
536
|
+
Toggles.GC.reports off → identical to a bare gc.collect() (no toast).
|
|
537
|
+
Toggles.memory_profile additionally walks the LIVE graph on the boot
|
|
538
|
+
pass (live_graph=True — the graph about to be frozen, and the boot
|
|
539
|
+
collect's price) and appends everything to PROFILE_LOG."""
|
|
540
|
+
import threading
|
|
541
|
+
from meltygui.core.runtime.toggles import Toggles
|
|
542
|
+
profile = _profile_enabled()
|
|
543
|
+
if not profile and (not Toggles.GC.reports or live_graph):
|
|
544
|
+
# The boot pass (live_graph) walks EVERYTHING tracked; SAVEALL
|
|
545
|
+
# would need a SECOND full walk to report what it reclaimed (observed:
|
|
546
|
+
# 5.4 s doubled to 10 s). Bare collect, timed, toast only.
|
|
547
|
+
t0 = time.perf_counter()
|
|
548
|
+
n = gc.collect()
|
|
549
|
+
ms = 1000 * (time.perf_counter() - t0)
|
|
550
|
+
tint = (1.0, 0.25, 0.2) if ms >= 300 else (1.0, 0.65, 0.2)
|
|
551
|
+
notify(f"gc: {label} {ms:.0f}ms [{threading.current_thread().name}] "
|
|
552
|
+
f"{n:,} unreachable — {reason or ''}",
|
|
553
|
+
tint=tint, tag="lag", stack=capture_stack())
|
|
554
|
+
return n
|
|
555
|
+
stamp = time.strftime("%H:%M:%S")
|
|
556
|
+
thread = threading.current_thread().name
|
|
557
|
+
lines = [f"===== {stamp} gc: {label} [{thread}]",
|
|
558
|
+
f"reason: {reason or '-'}",
|
|
559
|
+
f"gen counts={gc.get_count()} thresholds={gc.get_threshold()} "
|
|
560
|
+
f"frozen={gc.get_freeze_count()}"]
|
|
561
|
+
if live_graph and profile:
|
|
562
|
+
t0 = time.perf_counter()
|
|
563
|
+
live = gc.get_objects()
|
|
564
|
+
hist, total = _histogram(live, top=40)
|
|
565
|
+
samples = _samples(live, {tn for tn, _ in hist[:6]})
|
|
566
|
+
lines.append(f"--- LIVE graph before freeze: {total} tracked objects "
|
|
567
|
+
f"(walk {1000*(time.perf_counter()-t0):.0f}ms)")
|
|
568
|
+
for tn, n in hist:
|
|
569
|
+
lines.append(f" {n:>10} {tn}")
|
|
570
|
+
for tn, reprs in samples.items():
|
|
571
|
+
for r in reprs:
|
|
572
|
+
lines.append(f" e.g. {tn}: {r}")
|
|
573
|
+
del samples
|
|
574
|
+
lines.extend(_thread_report())
|
|
575
|
+
try:
|
|
576
|
+
lines.extend(_stale_world_report(live))
|
|
577
|
+
except Exception as e:
|
|
578
|
+
lines.append(f"--- STALE WORLD report failed: {e!r}")
|
|
579
|
+
del live
|
|
580
|
+
gc.set_debug(gc.DEBUG_SAVEALL)
|
|
581
|
+
t0 = time.perf_counter()
|
|
582
|
+
try:
|
|
583
|
+
n = gc.collect()
|
|
584
|
+
finally:
|
|
585
|
+
gc.set_debug(0)
|
|
586
|
+
ms = 1000 * (time.perf_counter() - t0)
|
|
587
|
+
garbage = gc.garbage
|
|
588
|
+
hist, total = _histogram(garbage, top=40)
|
|
589
|
+
samples = _samples(garbage, {tn for tn, _ in hist[:8]})
|
|
590
|
+
nbytes = _approx_bytes(garbage)
|
|
591
|
+
lines.append(f"--- CYCLIC garbage reclaimed: collect()={n} gc.garbage={total} "
|
|
592
|
+
f"~{_fmt_bytes(nbytes)} shallow ({ms:.0f}ms)")
|
|
593
|
+
lines.append(f"--- by type (top 40):")
|
|
594
|
+
for tn, cnt in hist:
|
|
595
|
+
lines.append(f" {cnt:>10} {tn}")
|
|
596
|
+
for tn, reprs in samples.items():
|
|
597
|
+
for r in reprs:
|
|
598
|
+
lines.append(f" e.g. {tn}: {r}")
|
|
599
|
+
del samples
|
|
600
|
+
lines.extend(_module_histogram(garbage))
|
|
601
|
+
lines.extend(_detail_rows(garbage))
|
|
602
|
+
# Release: SAVEALL kept the cycles alive via gc.garbage; dropping the
|
|
603
|
+
# reference leaves them unreachable again, and the follow-up (un-instrumented)
|
|
604
|
+
# collect actually frees them.
|
|
605
|
+
del garbage
|
|
606
|
+
gc.garbage.clear()
|
|
607
|
+
gc.collect()
|
|
608
|
+
if profile:
|
|
609
|
+
_write(lines)
|
|
610
|
+
report = _write_report(label, lines) if Toggles.GC.reports else None
|
|
611
|
+
# Toast: duration first (it is a lag entry), then what went - the top
|
|
612
|
+
# three types by short name. Click → the report file in the editor.
|
|
613
|
+
tops = " · ".join(f"{tn.rsplit('.', 1)[-1]} {cnt:,}" for tn, cnt in hist[:3]) or "nothing"
|
|
614
|
+
tint = (1.0, 0.25, 0.2) if ms >= 300 else (1.0, 0.65, 0.2)
|
|
615
|
+
notify(f"gc: {label} {ms:.0f}ms [{thread}] {total:,} objs ~{_fmt_bytes(nbytes)}: {tops}",
|
|
616
|
+
tint=tint, tag="lag", stack=capture_stack(),
|
|
617
|
+
jump=(str(report), 1) if report else None)
|
|
618
|
+
return n
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _boot_collect_and_freeze(label, trigger=""):
|
|
622
|
+
"""The once-per-session full pass: unfreeze → collect → freeze.
|
|
623
|
+
|
|
624
|
+
A studio "restart" is IN-PROCESS (model_server purges src.* from
|
|
625
|
+
sys.modules and re-imports), so this module — and its _state — is fresh
|
|
626
|
+
each session while the interpreter's permanent generation is not:
|
|
627
|
+
everything the PREVIOUS session froze is still parked there, where no
|
|
628
|
+
collect ever looks. Old modules/caches/draw_states are all cyclic, so
|
|
629
|
+
without unfreezing first every restart leaked the whole prior app graph
|
|
630
|
+
for good (observed: 44M frozen vs 1.6M live). unfreeze() moves the
|
|
631
|
+
permanent generation back into gen2 so this one collect reclaims the
|
|
632
|
+
dead prior sessions before we freeze anew.
|
|
633
|
+
|
|
634
|
+
Called from tick() at the first idle window, OR from collect_after_run
|
|
635
|
+
once boot_delay_s has passed: a pre-freeze collect walks the entire graph
|
|
636
|
+
(~900ms on 3M objects) whether or not we freeze after it, so a run that
|
|
637
|
+
lands before the idle window pays the full walk exactly once and freezes
|
|
638
|
+
right there instead of paying it again per run until idle."""
|
|
639
|
+
prev_frozen = gc.get_freeze_count()
|
|
640
|
+
gc.unfreeze()
|
|
641
|
+
_collect(f"{label} collect+freeze", live_graph=True,
|
|
642
|
+
reason=f"boot pass ({trigger or label}): unfreeze → full collect over the "
|
|
643
|
+
f"WHOLE tracked graph → freeze; cost = live graph size, not garbage")
|
|
644
|
+
gc.freeze()
|
|
645
|
+
_state["frozen"] = True
|
|
646
|
+
_state["last_collect"] = time.monotonic()
|
|
647
|
+
notify(f"gc: froze {gc.get_freeze_count()} objects out of gen2 scans"
|
|
648
|
+
f" (unfroze {prev_frozen} from prior sessions first)",
|
|
649
|
+
tint=(0.4, 0.9, 0.4), tag="lag", stack=capture_stack())
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def tick():
|
|
653
|
+
"""Once per frame from Melty.end_frame (render thread). Cheap when there
|
|
654
|
+
is nothing to do: two attribute reads and a couple of comparisons."""
|
|
655
|
+
from meltygui.core.melty import Melty
|
|
656
|
+
from meltygui.core.runtime.toggles import Toggles
|
|
657
|
+
if not Toggles.GC.manage:
|
|
658
|
+
if _state["applied"]:
|
|
659
|
+
gc.set_threshold(700, 10, 10) # stock CPython defaults
|
|
660
|
+
_state["applied"] = False
|
|
661
|
+
return
|
|
662
|
+
if not _state["applied"]:
|
|
663
|
+
gc.set_threshold(700, 10, int(Toggles.GC.gen2_threshold))
|
|
664
|
+
_state["applied"] = True
|
|
665
|
+
now = time.monotonic()
|
|
666
|
+
if now - _state["boot_t"] < Toggles.GC.boot_delay_s:
|
|
667
|
+
_state["last_tick"] = now
|
|
668
|
+
return
|
|
669
|
+
|
|
670
|
+
# Frames only run on events (the main loop parks in glfw.poll_events), so
|
|
671
|
+
# while the user is away this tick never fires; the last frame BACK saw
|
|
672
|
+
# "idle for ages" and collected right in the user's face. The solution:
|
|
673
|
+
# the focus-LOST edge (nobody is looking - the best possible moment to
|
|
674
|
+
# pay for a collect), a focus-GAIN / long frame gap, which restarts the
|
|
675
|
+
# idle clock so the collect needs idle_seconds of sleep measured from the
|
|
676
|
+
# return, and POINTER PRESENCE (motion inside the window, stamped in the
|
|
677
|
+
# input backend's cursor callback) - a return always starts with the
|
|
678
|
+
# pointer crossing the window, before any click or focus change.
|
|
679
|
+
#
|
|
680
|
+
# The focus edge is POLLED, so it is only trustworthy while frames flow.
|
|
681
|
+
# With frames parked, the first frame back can be the one that reads
|
|
682
|
+
# "unfocused" (pointer over the window, focus not regained yet) against the
|
|
683
|
+
# stale was_focused=True - a loss like that IS the return. So a loss only
|
|
684
|
+
# ARMS the collect; it fires on a later frame (a wake is scheduled) once
|
|
685
|
+
# the window has stayed unfocused, with no presence, for
|
|
686
|
+
# Toggles.GC.unfocus_confirm_s. Any presence or focus meanwhile disarms.
|
|
687
|
+
focused = _window_focused(Melty)
|
|
688
|
+
was_focused = _state["focused"]
|
|
689
|
+
_state["focused"] = focused
|
|
690
|
+
frame_gap = now - _state["last_tick"]
|
|
691
|
+
_state["last_tick"] = now
|
|
692
|
+
if (focused and not was_focused) or frame_gap >= Toggles.GC.idle_seconds:
|
|
693
|
+
_state["resumed_t"] = now
|
|
694
|
+
lost_focus = was_focused and not focused
|
|
695
|
+
last_input = max(getattr(Melty, "_last_input_time", 0.0),
|
|
696
|
+
getattr(Melty, "_last_presence_time", 0.0),
|
|
697
|
+
_state["resumed_t"])
|
|
698
|
+
|
|
699
|
+
pointer_inside = bool(getattr(Melty, "_pointer_inside", True))
|
|
700
|
+
if focused:
|
|
701
|
+
_state["unfocus_armed_t"] = 0.0
|
|
702
|
+
else:
|
|
703
|
+
if lost_focus:
|
|
704
|
+
_state["unfocus_armed_t"] = now
|
|
705
|
+
_wake_in(Toggles.GC.unfocus_confirm_s + 0.05)
|
|
706
|
+
armed_t = _state["unfocus_armed_t"]
|
|
707
|
+
if armed_t and now - armed_t >= Toggles.GC.unfocus_confirm_s:
|
|
708
|
+
_state["unfocus_armed_t"] = 0.0
|
|
709
|
+
# "Nobody is looking" = no presence was input since the arm
|
|
710
|
+
# (the pointer motion that WOKE the arming frame is stamped just
|
|
711
|
+
# before it, hence the margin) AND the pointer is off the window
|
|
712
|
+
# - unfocused with the pointer inside it is a return in progress
|
|
713
|
+
# or someone reading; the pass would land in their face.
|
|
714
|
+
quiet = last_input < armed_t - Toggles.GC.unfocus_confirm_s
|
|
715
|
+
if quiet and not pointer_inside:
|
|
716
|
+
if not _state["frozen"]:
|
|
717
|
+
_boot_collect_and_freeze(
|
|
718
|
+
"boot", trigger=f"window unfocused for {now - armed_t:.1f}s")
|
|
719
|
+
elif now - _state["last_collect"] >= Toggles.GC.unfocus_collect_s:
|
|
720
|
+
_collect("unfocus collect",
|
|
721
|
+
reason=f"window unfocused for {now - armed_t:.1f}s, "
|
|
722
|
+
f"last presence {now - last_input:.0f}s ago, "
|
|
723
|
+
f"last collect {now - _state['last_collect']:.0f}s ago")
|
|
724
|
+
_state["last_collect"] = now
|
|
725
|
+
return
|
|
726
|
+
if now - last_input < Toggles.GC.idle_seconds:
|
|
727
|
+
return
|
|
728
|
+
if not _state["frozen"]:
|
|
729
|
+
# The boot pass walks the WHOLE heap (seconds) and a 15 s pause in
|
|
730
|
+
# typing is not a real pause; the unfocused branch above is the
|
|
731
|
+
# best moment, and idle must be a long one.
|
|
732
|
+
if now - last_input < Toggles.GC.boot_idle_seconds:
|
|
733
|
+
return
|
|
734
|
+
_boot_collect_and_freeze(
|
|
735
|
+
"boot", trigger=f"no input / presence for {now - last_input:.0f}s")
|
|
736
|
+
elif now - _state["last_collect"] >= Toggles.GC.idle_collect_s:
|
|
737
|
+
_collect("idle collect",
|
|
738
|
+
reason=f"no input / presence for {now - last_input:.0f}s "
|
|
739
|
+
f"(idle_seconds={Toggles.GC.idle_seconds:g}), "
|
|
740
|
+
f"last collect {now - _state['last_collect']:.0f}s ago")
|
|
741
|
+
_state["last_collect"] = now
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _wake_in(delay_s):
|
|
745
|
+
"""Produce a frame `delay_s` from now (the loop parks in wait_events, so
|
|
746
|
+
the confirm tick above needs a wake to happen at all)."""
|
|
747
|
+
import threading
|
|
748
|
+
|
|
749
|
+
def _fire():
|
|
750
|
+
try:
|
|
751
|
+
from meltygui.core.windowing.glfw_utils import request_render
|
|
752
|
+
request_render()
|
|
753
|
+
except Exception:
|
|
754
|
+
pass
|
|
755
|
+
|
|
756
|
+
t = threading.Timer(max(0.0, float(delay_s)), _fire)
|
|
757
|
+
t.daemon = True
|
|
758
|
+
t.start()
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _window_focused(Melty) -> bool:
|
|
762
|
+
"""glfw FOCUSED of the studio window; True when there is no window yet
|
|
763
|
+
(tests / headless) so the idle path behaves as before."""
|
|
764
|
+
window = getattr(Melty, "glfw_window", None)
|
|
765
|
+
if window is None:
|
|
766
|
+
return True
|
|
767
|
+
try:
|
|
768
|
+
import meltygui.core.windowing.window_api as glfw
|
|
769
|
+
return bool(glfw.get_window_attrib(window, glfw.FOCUSED))
|
|
770
|
+
except Exception:
|
|
771
|
+
return True
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def collect_after_run(label="run"):
|
|
775
|
+
"""One full collect + CUDA cache release, called from a WORKER thread
|
|
776
|
+
right after a heavy run retires its previous generation (the live lab's
|
|
777
|
+
instrumented runs — live_instrument.run_instrumented). gen2's
|
|
778
|
+
auto-trigger is pushed out of reach and the idle collector above waits
|
|
779
|
+
for a quiet input window, but a run's cyclic garbage pins GPU tensors
|
|
780
|
+
(deepcopied component trees, the prior ForwardPassResult graph), and
|
|
781
|
+
VRAM can't wait minutes for idleness while the user is actively
|
|
782
|
+
iterating — observed as ~a full activation generation leaked per Run.
|
|
783
|
+
Post-freeze the pass only walks objects allocated since boot-freeze
|
|
784
|
+
(same price the idle collect pays), scheduled at the one moment it is
|
|
785
|
+
guaranteed profitable; the lag column keeps the cost visible.
|
|
786
|
+
|
|
787
|
+
RATE-LIMITED (Toggles.GC.post_run_min_s): Auto Execute fires a run per
|
|
788
|
+
param-drag tick, and a full collect per tick was a continuous ~120ms
|
|
789
|
+
stall. Runs inside the spacing window coalesce onto a trailing one-shot
|
|
790
|
+
timer that calls back here once the burst rests — so the LAST run's
|
|
791
|
+
garbage still retires promptly (that's the VRAM that matters), while a
|
|
792
|
+
burst pays at most one collect per window."""
|
|
793
|
+
from meltygui.core.runtime.toggles import Toggles
|
|
794
|
+
import threading
|
|
795
|
+
min_s = float(Toggles.GC.post_run_min_s or 0.0)
|
|
796
|
+
now = time.monotonic()
|
|
797
|
+
since = now - _state.get("last_post_run", 0.0)
|
|
798
|
+
# The CUDA cache release is NOT the expensive part (that's the heap
|
|
799
|
+
# walk), but it is what nvidia-smi actually sees: everything the run
|
|
800
|
+
# retired by refcount alone (no longer retain the live lab's per-run
|
|
801
|
+
# activations once their parents re-render) sit in torch's allocator
|
|
802
|
+
# cache until empty_cache. Release on its own short cadence so VRAM
|
|
803
|
+
# tracks the live set while typing, independent of the collect spacing.
|
|
804
|
+
rel_s = float(Toggles.GC.post_run_cache_release_s or 0.0)
|
|
805
|
+
rel_since = now - _state.get("last_cache_release", 0.0)
|
|
806
|
+
if rel_since >= rel_s:
|
|
807
|
+
_state["last_cache_release"] = now
|
|
808
|
+
_release_cuda_cache()
|
|
809
|
+
else:
|
|
810
|
+
# Inside the spacing window: DEFER, never skip - a typing burst's
|
|
811
|
+
# last run must still hand its freed blocks back once it rests.
|
|
812
|
+
release_cuda_cache_soon(rel_s - rel_since, label=label)
|
|
813
|
+
if min_s > 0.0 and since < min_s:
|
|
814
|
+
# Too soon - arm/replace the trailing timer timer. The timer re-enters
|
|
815
|
+
# this function; by then either the window has passed (collect) or
|
|
816
|
+
# newer runs re-armed a fresh timer (coalesce again).
|
|
817
|
+
prev = _state.get("post_run_timer")
|
|
818
|
+
if prev is not None:
|
|
819
|
+
prev.cancel()
|
|
820
|
+
t = threading.Timer(min_s - since, collect_after_run, args=(label,))
|
|
821
|
+
t.daemon = True
|
|
822
|
+
_state["post_run_timer"] = t
|
|
823
|
+
t.start()
|
|
824
|
+
return
|
|
825
|
+
_state["last_post_run"] = now
|
|
826
|
+
if not _state["frozen"] and now - _state["boot_t"] >= Toggles.GC.boot_delay_s:
|
|
827
|
+
# Not frozen yet - this collect walks everything anyway; make it THE
|
|
828
|
+
# boot pass so future runs (and idle) get the cheap post-freeze walk.
|
|
829
|
+
_boot_collect_and_freeze(f"post-{label}", trigger=f"run '{label}' before the idle boot pass")
|
|
830
|
+
_release_cuda_cache()
|
|
831
|
+
return
|
|
832
|
+
_collect(f"post-{label} collect", reason=f"run '{label}' retired its previous generation")
|
|
833
|
+
_release_cuda_cache()
|
|
834
|
+
_state["last_collect"] = time.monotonic()
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def release_cuda_cache_soon(delay_s=0.5, label="release"):
|
|
838
|
+
"""torch.cuda.empty_cache() shortly, OFF the render thread, coalesced: a
|
|
839
|
+
burst of releases (closing several live views, a prune sweep, runs inside
|
|
840
|
+
the post_run_cache_release_s window) pays one call. Freed tensors only
|
|
841
|
+
leave the allocator's cache — and nvidia-smi / the studio's VRAM readout
|
|
842
|
+
— on empty_cache, and a close has no run behind it to trigger
|
|
843
|
+
collect_after_run's release."""
|
|
844
|
+
import threading
|
|
845
|
+
prev = _state.get("cache_release_timer")
|
|
846
|
+
if prev is not None:
|
|
847
|
+
prev.cancel()
|
|
848
|
+
|
|
849
|
+
def _fire():
|
|
850
|
+
_state["cache_release_timer"] = None
|
|
851
|
+
_state["last_cache_release"] = time.monotonic()
|
|
852
|
+
_release_cuda_cache()
|
|
853
|
+
|
|
854
|
+
t = threading.Timer(max(0.0, float(delay_s)), _fire)
|
|
855
|
+
t.daemon = True
|
|
856
|
+
_state["cache_release_timer"] = t
|
|
857
|
+
t.start()
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _release_cuda_cache():
|
|
861
|
+
try:
|
|
862
|
+
import torch
|
|
863
|
+
if torch.cuda.is_available():
|
|
864
|
+
torch.cuda.empty_cache()
|
|
865
|
+
except Exception:
|
|
866
|
+
pass
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
# ── CUDA out-of-memory response ─────────────────────────────────────────────
|
|
870
|
+
# After an OOM the live set IS the VRAM: a partial run's accumulator stacks,
|
|
871
|
+
# every live-view window's pinned generation, the runners' previous results,
|
|
872
|
+
# plus whatever the failed run's traceback cycles hold - and nothing retires
|
|
873
|
+
# any of it (the idle/post-run collects are gated or deferred, and
|
|
874
|
+
# empty_cache can't nothing anything is still referenced). Left alone, a
|
|
875
|
+
# later run OOMs too and the only way out was a full reset. The responder
|
|
876
|
+
# dumps out of state deliberately, runs a REAL gc.collect (the exception →
|
|
877
|
+
# traceback → frame cycles are exactly what pins the failed generation), and
|
|
878
|
+
# empties the CUDA cache on every device, then retries what came back.
|
|
879
|
+
#
|
|
880
|
+
# Modules that hold big live state register a releaser here (called with no
|
|
881
|
+
# args, any thread, must not raise) - draw_function's runner threads do.
|
|
882
|
+
OOM_RELEASE_HOOKS = globals().get("OOM_RELEASE_HOOKS") or []
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def is_cuda_oom(exc):
|
|
886
|
+
"""True for a CUDA allocation failure however it surfaced: torch's
|
|
887
|
+
OutOfMemoryError, the runtime-API 'CUDA error: out of memory'
|
|
888
|
+
RuntimeError (e.g. from a custom kernel's context), pycuda's
|
|
889
|
+
MemoryError, or a GL/CUDA interop refusal carrying the same words."""
|
|
890
|
+
if exc is None:
|
|
891
|
+
return False
|
|
892
|
+
try:
|
|
893
|
+
import torch
|
|
894
|
+
if isinstance(exc, torch.cuda.OutOfMemoryError):
|
|
895
|
+
return True
|
|
896
|
+
except Exception:
|
|
897
|
+
pass
|
|
898
|
+
name = type(exc).__name__
|
|
899
|
+
msg = str(exc).lower()
|
|
900
|
+
if name == "MemoryError" and type(exc).__module__.startswith("pycuda"):
|
|
901
|
+
return True
|
|
902
|
+
return "out of memory" in msg and ("cuda" in msg or "cublas" in msg
|
|
903
|
+
or name == "OutOfMemoryError")
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def _device_mem():
|
|
907
|
+
try:
|
|
908
|
+
import torch
|
|
909
|
+
if not torch.cuda.is_available():
|
|
910
|
+
return {}
|
|
911
|
+
return {i: (torch.cuda.memory_allocated(i), torch.cuda.memory_reserved(i))
|
|
912
|
+
for i in range(torch.cuda.device_count())}
|
|
913
|
+
except Exception:
|
|
914
|
+
return {}
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
_REPORT_SKIP = ("_describe_holder", "report_vram_holders", "_oom_cleanup", "_dict_slot")
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _dict_slot(d, obj):
|
|
921
|
+
for k, v in list(d.items()):
|
|
922
|
+
if v is obj:
|
|
923
|
+
return k
|
|
924
|
+
return None
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def _describe_holder(obj, depth, seen, lines, prefix, internal):
|
|
928
|
+
"""One line per referrer of `obj` (up to `depth` hops), naming what kind
|
|
929
|
+
of container holds it and, where cheap, WHICH slot: dict key, attribute
|
|
930
|
+
name on an instance, frame code name, module/function name. No
|
|
931
|
+
closures in here — a genexpr capturing `obj` would show up as a cell."""
|
|
932
|
+
if depth <= 0 or id(obj) in seen:
|
|
933
|
+
return
|
|
934
|
+
seen.add(id(obj))
|
|
935
|
+
import types
|
|
936
|
+
try:
|
|
937
|
+
refs = gc.get_referrers(obj)
|
|
938
|
+
except Exception:
|
|
939
|
+
return
|
|
940
|
+
internal.add(id(refs))
|
|
941
|
+
shown = 0
|
|
942
|
+
for r in refs:
|
|
943
|
+
if id(r) in internal or r is lines or r is seen:
|
|
944
|
+
continue
|
|
945
|
+
if isinstance(r, types.FrameType) and r.f_code.co_name in _REPORT_SKIP:
|
|
946
|
+
continue
|
|
947
|
+
tn = type(r).__name__
|
|
948
|
+
if isinstance(r, dict):
|
|
949
|
+
key = _dict_slot(r, obj)
|
|
950
|
+
owner = None
|
|
951
|
+
for rr in gc.get_referrers(r):
|
|
952
|
+
if getattr(rr, "__dict__", None) is r:
|
|
953
|
+
owner = rr
|
|
954
|
+
break
|
|
955
|
+
if isinstance(rr, dict):
|
|
956
|
+
nm = _dict_slot(rr, r)
|
|
957
|
+
if nm is not None:
|
|
958
|
+
owner = f"dict[{nm!r}]"
|
|
959
|
+
break
|
|
960
|
+
if owner is not None and not isinstance(owner, str):
|
|
961
|
+
oname = (getattr(owner, "__qualname__", None) or getattr(owner, "__name__", None)
|
|
962
|
+
or getattr(owner, "name", None) or type(owner).__name__)
|
|
963
|
+
label = f"attr {key!r} of {type(owner).__name__} {oname}"
|
|
964
|
+
else:
|
|
965
|
+
label = f"dict[{key!r}]" + (f" in {owner}" if owner else "")
|
|
966
|
+
elif isinstance(r, (list, tuple, set, frozenset)):
|
|
967
|
+
label = f"{tn}[{len(r)}]"
|
|
968
|
+
elif isinstance(r, types.FrameType):
|
|
969
|
+
label = (f"frame {r.f_code.co_name} "
|
|
970
|
+
f"({r.f_code.co_filename.rsplit('/', 1)[-1]}:{r.f_lineno})")
|
|
971
|
+
elif isinstance(r, types.CellType):
|
|
972
|
+
label = "closure cell"
|
|
973
|
+
else:
|
|
974
|
+
slot = None
|
|
975
|
+
for a in getattr(r, "__slots__", ()):
|
|
976
|
+
if getattr(r, a, None) is obj:
|
|
977
|
+
slot = a
|
|
978
|
+
break
|
|
979
|
+
if slot is None:
|
|
980
|
+
try: # 3.12 inline class dicts: the instance IS the referrer
|
|
981
|
+
slot = _dict_slot(vars(r), obj)
|
|
982
|
+
except TypeError:
|
|
983
|
+
pass
|
|
984
|
+
label = tn + (f".{slot}" if slot else "")
|
|
985
|
+
qn = getattr(r, "__qualname__", None)
|
|
986
|
+
if isinstance(qn, str):
|
|
987
|
+
label += f" {qn}"
|
|
988
|
+
lines.append(f"{prefix}<- {label}")
|
|
989
|
+
shown += 1
|
|
990
|
+
if shown >= 4:
|
|
991
|
+
lines.append(f"{prefix} (+{len(refs) - shown} more referrers)")
|
|
992
|
+
break
|
|
993
|
+
if not isinstance(r, types.FrameType):
|
|
994
|
+
_describe_holder(r, depth - 1, seen, lines, prefix + " ", internal)
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def report_vram_holders(top=12, device=None, depth=3):
|
|
998
|
+
"""Who holds the VRAM: every CUDA tensor reachable from gc, aggregated
|
|
999
|
+
by STORAGE (views share one), the top-N storages by bytes with the
|
|
1000
|
+
referrer chain of one tensor over each. Unfreezes the permanent
|
|
1001
|
+
generation for the walk (gc.get_objects skips it) and re-freezes.
|
|
1002
|
+
Parameters show up too — the model's weights are part of the answer.
|
|
1003
|
+
Returns the report lines (also printed). Seconds, OOM-time only."""
|
|
1004
|
+
t0 = time.perf_counter()
|
|
1005
|
+
# Unfrozen for the WHOLE report: neither get_objects nor get_referrers
|
|
1006
|
+
# looks through the permanent generation. Deliberately NOT re-frozen
|
|
1007
|
+
# here: gc.freeze() freezes EVERYTHING tracked - including whatever
|
|
1008
|
+
# cyclic garbage is pending - and a frozen cycle can never be collected
|
|
1009
|
+
# (an earlier version did this and pinned 28 retired generations). The
|
|
1010
|
+
# OOM handler re-freezes after its collect (the boot regime); a manual
|
|
1011
|
+
# call leaves the heap unfrozen, which only makes the next collect walk
|
|
1012
|
+
# more.
|
|
1013
|
+
gc.unfreeze()
|
|
1014
|
+
return _report_vram_holders(top, device, depth, t0)
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def _report_vram_holders(top, device, depth, t0):
|
|
1018
|
+
objs = gc.get_objects()
|
|
1019
|
+
by_storage = {}
|
|
1020
|
+
n_tensors = 0
|
|
1021
|
+
for o in objs:
|
|
1022
|
+
if type(o).__name__ not in ("Tensor", "Parameter"):
|
|
1023
|
+
continue
|
|
1024
|
+
try:
|
|
1025
|
+
if not o.is_cuda or (device is not None and (o.device.index or 0) != device):
|
|
1026
|
+
continue
|
|
1027
|
+
st = o.untyped_storage()
|
|
1028
|
+
key = (st.data_ptr(), o.device.index or 0)
|
|
1029
|
+
nbytes = st.nbytes()
|
|
1030
|
+
except Exception:
|
|
1031
|
+
continue
|
|
1032
|
+
n_tensors += 1
|
|
1033
|
+
ent = by_storage.get(key)
|
|
1034
|
+
if ent is None:
|
|
1035
|
+
by_storage[key] = [nbytes, o.device.index or 0, [o]]
|
|
1036
|
+
elif len(ent[2]) < 4:
|
|
1037
|
+
ent[2].append(o) # the full tensor AND its views: a VIEW held
|
|
1038
|
+
# by a draw call holds the storage just as well
|
|
1039
|
+
del objs, o
|
|
1040
|
+
total = 0
|
|
1041
|
+
per_dev = {}
|
|
1042
|
+
for nb, d, _t in by_storage.values():
|
|
1043
|
+
total += nb
|
|
1044
|
+
per_dev[d] = per_dev.get(d, 0) + nb
|
|
1045
|
+
del _t
|
|
1046
|
+
ranked = sorted(by_storage.values(), key=lambda e: -e[0])
|
|
1047
|
+
# Representatives in ONE flat list the describer knows to skip; the
|
|
1048
|
+
# bookkeeping containers above are released before any referrer walk.
|
|
1049
|
+
reps = []
|
|
1050
|
+
for e in ranked[:top]:
|
|
1051
|
+
reps.append((e[0], e[1], tuple(e[2])))
|
|
1052
|
+
small = 0
|
|
1053
|
+
for e in ranked[top:]:
|
|
1054
|
+
small += e[0]
|
|
1055
|
+
n_storages = len(by_storage)
|
|
1056
|
+
del by_storage, ranked, e, ent
|
|
1057
|
+
lines = [f"=== VRAM holders: {n_tensors} CUDA tensors over {n_storages} storages, "
|
|
1058
|
+
f"{total/2**30:.1f} GB reachable (walk {time.perf_counter()-t0:.1f}s)",
|
|
1059
|
+
" per device: " + ", ".join(f"cuda:{d} {v/2**30:.1f} GB"
|
|
1060
|
+
for d, v in sorted(per_dev.items()))]
|
|
1061
|
+
seen = set()
|
|
1062
|
+
internal = {id(reps), id(per_dev)}
|
|
1063
|
+
for e in reps:
|
|
1064
|
+
internal.add(id(e))
|
|
1065
|
+
internal.add(id(e[2]))
|
|
1066
|
+
del e
|
|
1067
|
+
for i in range(len(reps)):
|
|
1068
|
+
nb, d, ts = reps[i]
|
|
1069
|
+
lines.append(f"- {nb/2**30:6.2f} GB cuda:{d} storage, {len(ts)} tensor(s) over it:")
|
|
1070
|
+
for t in ts:
|
|
1071
|
+
lines.append(f" {type(t).__name__}{tuple(t.shape)} "
|
|
1072
|
+
f"{str(t.dtype).replace('torch.', '')}"
|
|
1073
|
+
f"{' (view)' if t.numel() * t.element_size() < nb else ''}")
|
|
1074
|
+
_describe_holder(t, depth, seen, lines, " ", internal)
|
|
1075
|
+
del t, ts
|
|
1076
|
+
lines.append(f" (+{small/2**30:.1f} GB in {max(0, n_storages - top)} smaller storages)")
|
|
1077
|
+
text = "\n".join(lines)
|
|
1078
|
+
print(text)
|
|
1079
|
+
return lines
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def _oom_cleanup(where):
|
|
1083
|
+
import threading
|
|
1084
|
+
_state["oom_timer"] = None
|
|
1085
|
+
before = _device_mem()
|
|
1086
|
+
if Toggles.GC.oom_holder_report:
|
|
1087
|
+
try:
|
|
1088
|
+
report_vram_holders()
|
|
1089
|
+
except Exception as e:
|
|
1090
|
+
print(f"[gc] oom: holder report failed: {e!r}")
|
|
1091
|
+
dropped = 0
|
|
1092
|
+
try:
|
|
1093
|
+
from meltygui.code.live_view import release_all_live_stores
|
|
1094
|
+
dropped = release_all_live_stores(discover=True)
|
|
1095
|
+
except Exception as e:
|
|
1096
|
+
print(f"[gc] oom: release_all_live_stores failed: {e!r}")
|
|
1097
|
+
for hook in list(OOM_RELEASE_HOOKS):
|
|
1098
|
+
try:
|
|
1099
|
+
hook()
|
|
1100
|
+
except Exception as e:
|
|
1101
|
+
print(f"[gc] oom: release hook {getattr(hook, '__name__', hook)} failed: {e!r}")
|
|
1102
|
+
# A real collect, regardless of the profiler/idle gating: the failed
|
|
1103
|
+
# run's pending cycles are the generation that must die. Unfreeze
|
|
1104
|
+
# first (prior runs / an earlier report may have frozen garbage),
|
|
1105
|
+
# re-freeze what remains for the boot regime: later collects only walk
|
|
1106
|
+
# what's new.
|
|
1107
|
+
t0 = time.perf_counter()
|
|
1108
|
+
try:
|
|
1109
|
+
gc.unfreeze()
|
|
1110
|
+
n = gc.collect()
|
|
1111
|
+
gc.freeze()
|
|
1112
|
+
except Exception:
|
|
1113
|
+
n = -1
|
|
1114
|
+
_release_cuda_cache()
|
|
1115
|
+
try:
|
|
1116
|
+
import torch
|
|
1117
|
+
if torch.cuda.is_available():
|
|
1118
|
+
for i in range(torch.cuda.device_count()):
|
|
1119
|
+
with torch.cuda.device(i):
|
|
1120
|
+
torch.cuda.empty_cache()
|
|
1121
|
+
except Exception:
|
|
1122
|
+
pass
|
|
1123
|
+
after = _device_mem()
|
|
1124
|
+
parts = []
|
|
1125
|
+
for i in sorted(set(before) | set(after)):
|
|
1126
|
+
ba, br = before.get(i, (0, 0))
|
|
1127
|
+
aa, ar = after.get(i, (0, 0))
|
|
1128
|
+
parts.append(f"cuda:{i} reserved {br/2**30:.1f}→{ar/2**30:.1f} GB "
|
|
1129
|
+
f"(allocated {ba/2**30:.1f}→{aa/2**30:.1f})")
|
|
1130
|
+
msg = (f"CUDA OOM in {where}: released {dropped} live keys, "
|
|
1131
|
+
f"gc {n} objs in {1000*(time.perf_counter()-t0):.0f}ms; "
|
|
1132
|
+
+ "; ".join(parts))
|
|
1133
|
+
print(f"[gc] {msg}")
|
|
1134
|
+
try:
|
|
1135
|
+
notify(msg, tint=(1.0, 0.55, 0.3), tag="oom")
|
|
1136
|
+
except Exception:
|
|
1137
|
+
pass
|
|
1138
|
+
try:
|
|
1139
|
+
from meltygui.core.windowing.glfw_utils import request_render
|
|
1140
|
+
request_render()
|
|
1141
|
+
except Exception:
|
|
1142
|
+
pass
|
|
1143
|
+
_state["last_post_run"] = _state["last_collect"] = time.monotonic()
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def respond_to_cuda_oom(exc=None, where="run", delay_s=0.25):
|
|
1147
|
+
"""Call from an except block that caught `exc` (or with exc=None to
|
|
1148
|
+
force). No-op unless is_cuda_oom(exc). The cleanup itself is DEFERRED
|
|
1149
|
+
onto a short timer thread — it must run after the raising frames have
|
|
1150
|
+
unwound (while the handler runs, the traceback still pins the failed
|
|
1151
|
+
run's tensors, and a collect there frees nothing) — and coalesced, so a
|
|
1152
|
+
burst of failures pays one sweep. Returns whether a cleanup was armed."""
|
|
1153
|
+
import threading
|
|
1154
|
+
if exc is not None and not is_cuda_oom(exc):
|
|
1155
|
+
return False
|
|
1156
|
+
prev = _state.get("oom_timer")
|
|
1157
|
+
if prev is not None:
|
|
1158
|
+
prev.cancel()
|
|
1159
|
+
t = threading.Timer(max(0.0, float(delay_s)), _oom_cleanup, args=(where,))
|
|
1160
|
+
t.daemon = True
|
|
1161
|
+
_state["oom_timer"] = t
|
|
1162
|
+
t.start()
|
|
1163
|
+
return True
|