已合并
[inductor][Feature] Adaptive Gear Update #35530
zhudada0120创建于 5月13日
[inductor][Feature] Adaptive Gear Update #35530
已合并
共 7 个文件变更+2690-56
| @@ -1,5 +1,7 @@ | |||
| 1 | import unittest | 1 | import unittest |
| 2 | from unittest import mock | 2 | from unittest import mock |
| 3 | +import threading | ||
| 4 | +import time | ||
| 3 | 5 | ||
| 4 | import torch | 6 | import torch |
| 5 | from torch.nn.parallel import scatter_gather | 7 | from torch.nn.parallel import scatter_gather |
| @@ -62,46 +64,80 @@ shape_options = { | |||
| 62 | 64 | ||
| 63 | class TestShapeHandling(TestCase): | 65 | class TestShapeHandling(TestCase): |
| 64 | def test_init_no_input(self): | 66 | def test_init_no_input(self): |
| 67 | + """Constructing NPUShapeHandling with no arguments should succeed (delay_init path).""" | ||
| 65 | shape_handling = torch_npu._inductor.NPUShapeHandling() | 68 | shape_handling = torch_npu._inductor.NPUShapeHandling() |
| 69 | + # object created successfully | ||
| 66 | self.assertNotEqual(shape_handling, None) | 70 | self.assertNotEqual(shape_handling, None) |
| 67 | - | 71 | + |
| 68 | def test_init_with_empty_conifg(self): | 72 | def test_init_with_empty_conifg(self): |
| 73 | + """Empty config list should not crash — falls through to delay_init.""" | ||
| 69 | configs = [] | 74 | configs = [] |
| 70 | shape_handling = torch_npu._inductor.NPUShapeHandling(configs) | 75 | shape_handling = torch_npu._inductor.NPUShapeHandling(configs) |
| 71 | self.assertNotEqual(shape_handling, None) | 76 | self.assertNotEqual(shape_handling, None) |
| 72 | - | 77 | + |
| 73 | def test_init_with_gears(self): | 78 | def test_init_with_gears(self): |
| 79 | + """Explicit gears for two dimension types should be accepted.""" | ||
| 74 | configs = [ | 80 | configs = [ |
| 75 | - { | 81 | + {"type": "BATCHSIZE", "gears": [16, 32, 64]}, |
| 76 | - "type": "BATCHSIZE", | 82 | + {"type": "SEQLEN", "gears": [16, 32, 64]}, |
| 77 | - "gears": [16, 32, 64] | ||
| 78 | - }, | ||
| 79 | - { | ||
| 80 | - "type": "SEQLEN", | ||
| 81 | - "gears": [16, 32, 64] | ||
| 82 | - } | ||
| 83 | ] | 83 | ] |
| 84 | shape_handling = torch_npu._inductor.NPUShapeHandling(configs) | 84 | shape_handling = torch_npu._inductor.NPUShapeHandling(configs) |
| 85 | self.assertNotEqual(shape_handling, None) | 85 | self.assertNotEqual(shape_handling, None) |
| 86 | - | 86 | + |
| 87 | def test_init_with_policy(self): | 87 | def test_init_with_policy(self): |
| 88 | + """TIMES policy with min/max should auto-generate gears.""" | ||
| 88 | configs = [ | 89 | configs = [ |
| 89 | - { | 90 | + {"type": "BATCHSIZE", "min_size": 2, "max_size": 8, "policy": "TIMES"}, |
| 90 | - "type": "BATCHSIZE", | 91 | + {"type": "SEQLEN", "min_size": 2, "max_size": 8, "policy": "TIMES"}, |
| 91 | - "min_size": 2, | ||
| 92 | - "max_size": 8, | ||
| 93 | - "policy": "TIMES" | ||
| 94 | - }, | ||
| 95 | - { | ||
| 96 | - "type": "SEQLEN", | ||
| 97 | - "min_size": 2, | ||
| 98 | - "max_size": 8, | ||
| 99 | - "policy": "TIMES" | ||
| 100 | - } | ||
| 101 | ] | 92 | ] |
| 102 | shape_handling = torch_npu._inductor.NPUShapeHandling(configs) | 93 | shape_handling = torch_npu._inductor.NPUShapeHandling(configs) |
| 103 | self.assertNotEqual(shape_handling, None) | 94 | self.assertNotEqual(shape_handling, None) |
| 104 | - | 95 | + |
| 96 | + def test_normalize_configs_edge_cases(self): | ||
| 97 | + """_normalize_configs handles empty input, dedup, float→int, TIMES expansion.""" | ||
| 98 | + from torch_npu._inductor.adaptive_gears import AdaptiveGearRuntime | ||
| 99 | + from unittest.mock import MagicMock | ||
| 100 | + | ||
| 101 | + runtime = MagicMock(spec=AdaptiveGearRuntime) | ||
| 102 | + runtime._expand_policy_gears = lambda config: AdaptiveGearRuntime._expand_policy_gears(runtime, config) | ||
| 103 | + | ||
| 104 | + # Empty / None input → returns empty list without crashing | ||
| 105 | + self.assertEqual(AdaptiveGearRuntime._normalize_configs(runtime, []), []) | ||
| 106 | + self.assertEqual(AdaptiveGearRuntime._normalize_configs(runtime, None), []) | ||
| 107 | + | ||
| 108 | + # Duplicate gears deduped and sorted ascending | ||
| 109 | + config = {"type": "BATCHSIZE", "gears": [64, 16, 32, 16]} | ||
| 110 | + result = AdaptiveGearRuntime._normalize_configs(runtime, [config]) | ||
| 111 | + self.assertEqual(result[0]["gears"], [16, 32, 64]) | ||
| 112 | + | ||
| 113 | + # Float gears (e.g. from JSON) cast to int | ||
| 114 | + config = {"type": "BATCHSIZE", "gears": [16.0, 32.0]} | ||
| 115 | + result = AdaptiveGearRuntime._normalize_configs(runtime, [config]) | ||
| 116 | + self.assertEqual(result[0]["gears"], [16, 32]) | ||
| 117 | + # each value is a plain int | ||
| 118 | + self.assertIsInstance(result[0]["gears"][0], int) | ||
| 119 | + | ||
| 120 | + # TIMES: min == max → single gear (no expansion needed) | ||
| 121 | + config = {"type": "BATCHSIZE", "min_size": 4, "max_size": 4} | ||
| 122 | + result = AdaptiveGearRuntime._normalize_configs(runtime, [config]) | ||
| 123 | + self.assertEqual(result[0]["gears"], [4]) | ||
| 124 | + | ||
| 125 | + # TIMES: non-power-of-2 min_size → min preserved, then powers of 2, max appended | ||
| 126 | + config = {"type": "BATCHSIZE", "min_size": 3, "max_size": 100} | ||
| 127 | + result = AdaptiveGearRuntime._normalize_configs(runtime, [config]) | ||
| 128 | + # min_size is the anchor | ||
| 129 | + self.assertEqual(result[0]["gears"][0], 3) | ||
| 130 | + # max_size is the cap | ||
| 131 | + self.assertEqual(result[0]["gears"][-1], 100) | ||
| 132 | + for g in result[0]["gears"][1:-1]: | ||
| 133 | + # power-of-2 check | ||
| 134 | + self.assertEqual(g & (g - 1), 0, f"{g} is not a power of 2") | ||
| 135 | + | ||
| 136 | + # TIMES: power-of-2 min_size → next power of 2, then double | ||
| 137 | + config = {"type": "BATCHSIZE", "min_size": 8, "max_size": 128} | ||
| 138 | + result = AdaptiveGearRuntime._normalize_configs(runtime, [config]) | ||
| 139 | + self.assertEqual(result[0]["gears"], [8, 16, 32, 64, 128]) | ||
| 140 | + | ||
| 105 | def test_transform_no_operation(self): | 141 | def test_transform_no_operation(self): |
| 106 | configs = [ | 142 | configs = [ |
| 107 | { | 143 | { |
| @@ -187,9 +223,12 @@ class TestShapeHandling(TestCase): | |||
| 187 | input_tensor = torch.randn(200, 96) # 超过max_size | 223 | input_tensor = torch.randn(200, 96) # 超过max_size |
| 188 | outputs = shape_handling.transform([input_tensor]) | 224 | outputs = shape_handling.transform([input_tensor]) |
| 189 | # 验证分割结果:分割为两个组,第一段128,第二段72,再填充为128 | 225 | # 验证分割结果:分割为两个组,第一段128,第二段72,再填充为128 |
| 190 | - self.assertEqual(len(outputs), 2) # 分割为两个组 | 226 | + # 分割为两个组 |
| 191 | - self.assertEqual(outputs[0][0].shape, (128, 128)) # 第一段 | 227 | + self.assertEqual(len(outputs), 2) |
| 192 | - self.assertEqual(outputs[1][0].shape, (128, 128)) # 第二段 | 228 | + # 第一段 |
| 229 | + self.assertEqual(outputs[0][0].shape, (128, 128)) | ||
| 230 | + # 第二段 | ||
| 231 | + self.assertEqual(outputs[1][0].shape, (128, 128)) | ||
| 193 | 232 | ||
| 194 | def test_recover_padding(self): | 233 | def test_recover_padding(self): |
| 195 | """测试恢复填充的张量""" | 234 | """测试恢复填充的张量""" |
| @@ -375,6 +414,40 @@ class TestShapeHandlingBranchCoverage(TestCase): | |||
| 375 | [{"type": "BATCHSIZE"}, {"type": "SEQLEN"}, {"type": "BATCHSIZE"}] | 414 | [{"type": "BATCHSIZE"}, {"type": "SEQLEN"}, {"type": "BATCHSIZE"}] |
| 376 | ) | 415 | ) |
| 377 | 416 | ||
| 417 | + def test_validate_adaptive_configs(self): | ||
| 418 | + """Test adaptive config validation: unknown keys, range errors, type compatibility.""" | ||
| 419 | + shape_handling = torch_npu._inductor.NPUShapeHandling() | ||
| 420 | + | ||
| 421 | + # Unknown key | ||
| 422 | + with self.assertRaises(ValueError): | ||
| 423 | + shape_handling._validate_adaptive_configs({"unknown_key": 1}) | ||
| 424 | + | ||
| 425 | + # Ratio out of range | ||
| 426 | + with self.assertRaises(ValueError): | ||
| 427 | + shape_handling._validate_adaptive_configs({"weight_hit": 1.5}) | ||
| 428 | + with self.assertRaises(ValueError): | ||
| 429 | + shape_handling._validate_adaptive_configs({"pad_add_threshold": -0.1}) | ||
| 430 | + | ||
| 431 | + # Seconds must be >= 0 | ||
| 432 | + with self.assertRaises(ValueError): | ||
| 433 | + shape_handling._validate_adaptive_configs({"window_seconds": -1}) | ||
| 434 | + # Zero is allowed (business layer guards against division-by-zero) | ||
| 435 | + shape_handling._validate_adaptive_configs({ | ||
| 436 | + "window_seconds": 0, | ||
| 437 | + "recent_use_protect_seconds": 0, | ||
| 438 | + "update_interval_seconds": 0, | ||
| 439 | + }) | ||
| 440 | + | ||
| 441 | + # Int must be >= 1 | ||
| 442 | + with self.assertRaises(ValueError): | ||
| 443 | + shape_handling._validate_adaptive_configs({"min_samples_per_gear": 0}) | ||
| 444 | + | ||
| 445 | + # Int accepted for float fields (ratio, seconds) | ||
| 446 | + shape_handling._validate_adaptive_configs({ | ||
| 447 | + "weight_hit": 1, # int for ratio field | ||
| 448 | + "window_seconds": 300, # int for seconds field | ||
| 449 | + }) | ||
| 450 | + | ||
| 378 | def test_construct_indices_branches(self): | 451 | def test_construct_indices_branches(self): |
| 379 | shape_handling = torch_npu._inductor.NPUShapeHandling() | 452 | shape_handling = torch_npu._inductor.NPUShapeHandling() |
| 380 | tensors = [torch.randn(2, 3), torch.randn(2)] | 453 | tensors = [torch.randn(2, 3), torch.randn(2)] |
| @@ -455,7 +528,7 @@ class TestShapeHandlingBranchCoverage(TestCase): | |||
| 455 | 528 | ||
| 456 | def post_fn(outputs): | 529 | def post_fn(outputs): |
| 457 | recorded["post"] = outputs | 530 | recorded["post"] = outputs |
| 458 | - return [("ok",)], [{"done": True}] | 531 | + return [["ok"]], [{"done": True}] |
| 459 | 532 | ||
| 460 | shape_handling_custom = torch_npu._inductor.NPUShapeHandling( | 533 | shape_handling_custom = torch_npu._inductor.NPUShapeHandling( |
| 461 | transform_pre_fn=pre_fn, | 534 | transform_pre_fn=pre_fn, |
| @@ -467,7 +540,7 @@ class TestShapeHandlingBranchCoverage(TestCase): | |||
| 467 | out_args, out_kwargs = shape_handling_custom.transform_hook(torch.tensor([7.0])) | 540 | out_args, out_kwargs = shape_handling_custom.transform_hook(torch.tensor([7.0])) |
| 468 | self.assertIn("pre", recorded) | 541 | self.assertIn("pre", recorded) |
| 469 | self.assertIn("post", recorded) | 542 | self.assertIn("post", recorded) |
| 470 | - self.assertEqual(out_args, [("ok",)]) | 543 | + self.assertEqual(out_args, [["ok"]]) |
| 471 | self.assertEqual(out_kwargs, [{"done": True}]) | 544 | self.assertEqual(out_kwargs, [{"done": True}]) |
| 472 | 545 | ||
| 473 | shape_handling_none = torch_npu._inductor.NPUShapeHandling(transform_post_fn=lambda _: None) | 546 | shape_handling_none = torch_npu._inductor.NPUShapeHandling(transform_post_fn=lambda _: None) |
| @@ -520,6 +593,643 @@ class TestShapeHandlingBranchCoverage(TestCase): | |||
| 520 | if hasattr(shape_handling_module.patch_shape_handling, "_is_patched"): | 593 | if hasattr(shape_handling_module.patch_shape_handling, "_is_patched"): |
| 521 | delattr(shape_handling_module.patch_shape_handling, "_is_patched") | 594 | delattr(shape_handling_module.patch_shape_handling, "_is_patched") |
| 522 | 595 | ||
| 596 | + def test_transform_metadata_collection(self): | ||
| 597 | + configs = [ | ||
| 598 | + { | ||
| 599 | + "type": "BATCHSIZE", | ||
| 600 | + "gears": [64], | ||
| 601 | + "dimensions": 0, | ||
| 602 | + "indices": [0], | ||
| 603 | + }, | ||
| 604 | + { | ||
| 605 | + "type": "SEQLEN", | ||
| 606 | + "gears": [128], | ||
| 607 | + "dimensions": [1], | ||
| 608 | + "indices": [0], | ||
| 609 | + }, | ||
| 610 | + ] | ||
| 611 | + shape_handling = torch_npu._inductor.NPUShapeHandling(configs) | ||
| 612 | + with mock.patch.object( | ||
| 613 | + torch_npu._inductor.NPUShapeHandling, | ||
| 614 | + "transform", | ||
| 615 | + return_value=[[torch.randn(64, 128)], [torch.randn(64, 128)]], | ||
| 616 | + ): | ||
| 617 | + _, metadata = shape_handling._transform_with_metadata(torch.randn(96, 96)) | ||
| 618 | + self.assertEqual(len(metadata), 2) | ||
| 619 | + self.assertEqual(metadata[0]["raw_gear_values"], [[96], [96]]) | ||
| 620 | + self.assertEqual(metadata[0]["mapped_gear_values"], [[64], [128]]) | ||
| 621 | + self.assertGreater(metadata[0]["pad_ratios"][1][0], 0.0) | ||
| 622 | + self.assertGreater(metadata[0]["split_ratios"][0][0], 0.0) | ||
| 623 | + | ||
| 624 | + def test_transform_metadata_collection_degrades_on_failure(self): | ||
| 625 | + """Metadata collection failure should not affect transform output.""" | ||
| 626 | + configs = [{"type": "BATCHSIZE", "gears": [32], "dimensions": 0, "indices": [0]}] | ||
| 627 | + shape_handling = torch_npu._inductor.NPUShapeHandling(configs) | ||
| 628 | + | ||
| 629 | + with mock.patch.object( | ||
| 630 | + torch_npu._inductor.NPUShapeHandling, "transform", | ||
| 631 | + return_value=[[torch.randn(32, 8)]], | ||
| 632 | + ): | ||
| 633 | + with mock.patch( | ||
| 634 | + "torch_npu._inductor.adaptive_gears.collect_transform_metadata", | ||
| 635 | + side_effect=RuntimeError("boom"), | ||
| 636 | + ): | ||
| 637 | + outputs, metadata = shape_handling._transform_with_metadata(torch.randn(16, 8)) | ||
| 638 | + | ||
| 639 | + # Transform output is intact | ||
| 640 | + self.assertEqual(len(outputs[0]), 1) | ||
| 641 | + # Metadata gracefully degraded to empty | ||
| 642 | + self.assertEqual(metadata, []) | ||
| 643 | + | ||
| 644 | +class TestAsyncWorkerAndConcurrency(TestCase): | ||
| 645 | + """测试异步Worker和并发控制机制""" | ||
| 646 | + | ||
| 647 | + def test_async_worker_creation_and_execution(self): | ||
| 648 | + """测试后台Worker线程异步执行 run_once""" | ||
| 649 | + shape_handling = torch_npu._inductor.NPUShapeHandling( | ||
| 650 | + configs=[ | ||
| 651 | + {"type": "BATCHSIZE", "gears": [16, 32], "dimensions": 0, "indices": [0]}, | ||
| 652 | + ], | ||
| 653 | + adaptive_configs={ | ||
| 654 | + "update_interval_seconds": 0.1, | ||
| 655 | + "min_samples_per_gear": 1, | ||
| 656 | + "min_gear_count_per_type": 1, | ||
| 657 | + }, | ||
| 658 | + ) | ||
| 659 | + manager = shape_handling.adaptive_manager | ||
| 660 | + # adaptive manager created | ||
| 661 | + self.assertIsNotNone(manager) | ||
| 662 | + # worker attached | ||
| 663 | + self.assertIsNotNone(manager.worker) | ||
| 664 | + # daemon thread running | ||
| 665 | + self.assertTrue(manager._worker_thread.is_alive()) | ||
| 666 | + | ||
| 667 | + run_event = threading.Event() | ||
| 668 | + run_thread_id = None | ||
| 669 | + | ||
| 670 | + def mock_run_once(ts): | ||
| 671 | + nonlocal run_thread_id | ||
| 672 | + run_thread_id = threading.current_thread().ident | ||
| 673 | + run_event.set() | ||
| 674 | + | ||
| 675 | + with mock.patch.object(manager.worker, "run_once", side_effect=mock_run_once): | ||
| 676 | + # worker called run_once | ||
| 677 | + self.assertTrue(run_event.wait(timeout=5.0)) | ||
| 678 | + | ||
| 679 | + # thread recorded id | ||
| 680 | + self.assertIsNotNone(run_thread_id) | ||
| 681 | + # ran on background thread | ||
| 682 | + self.assertNotEqual(run_thread_id, threading.current_thread().ident) | ||
| 683 | + manager.shutdown() | ||
| 684 | + | ||
| 685 | + def test_update_loop_survives_exception(self): | ||
| 686 | + """Worker thread should continue after run_once raises.""" | ||
| 687 | + shape_handling = torch_npu._inductor.NPUShapeHandling( | ||
| 688 | + configs=[ | ||
| 689 | + {"type": "BATCHSIZE", "gears": [16, 32], "dimensions": 0, "indices": [0]}, | ||
| 690 | + ], | ||
| 691 | + adaptive_configs={ | ||
| 692 | + "update_interval_seconds": 0.1, | ||
| 693 | + "min_samples_per_gear": 1, | ||
| 694 | + "min_gear_count_per_type": 1, | ||
| 695 | + }, | ||
| 696 | + ) | ||
| 697 | + manager = shape_handling.adaptive_manager | ||
| 698 | + | ||
| 699 | + call_count = 0 | ||
| 700 | + run_event = threading.Event() | ||
| 701 | + | ||
| 702 | + def mock_run_once(ts): | ||
| 703 | + nonlocal call_count | ||
| 704 | + call_count += 1 | ||
| 705 | + if call_count == 1: | ||
| 706 | + raise RuntimeError("simulated failure") | ||
| 707 | + run_event.set() | ||
| 708 | + | ||
| 709 | + with mock.patch.object(manager.worker, "run_once", side_effect=mock_run_once): | ||
| 710 | + # 2nd call succeeded | ||
| 711 | + self.assertTrue(run_event.wait(timeout=5.0)) | ||
| 712 | + | ||
| 713 | + # 1st raised, 2nd invoked → loop survived | ||
| 714 | + self.assertGreaterEqual(call_count, 2) | ||
| 715 | + manager.shutdown() | ||
| 716 | + | ||
| 717 | + def test_snapshot_isolation_after_update(self): | ||
| 718 | + """Snapshot taken before an update must still reflect old gear set (clone-on-read).""" | ||
| 719 | + shape_handling = torch_npu._inductor.NPUShapeHandling( | ||
| 720 | + configs=[ | ||
| 721 | + {"type": "BATCHSIZE", "gears": [16, 32], "dimensions": 0, "indices": [0]}, | ||
| 722 | + ], | ||
| 723 | + adaptive_configs={ | ||
| 724 | + "recent_use_protect_seconds": 1.0, | ||
| 725 | + "min_samples_per_gear": 1, | ||
| 726 | + "min_gear_count_per_type": 1, | ||
| 727 | + }, | ||
| 728 | + ) | ||
| 729 | + manager = shape_handling.adaptive_manager | ||
| 730 | + | ||
| 731 | + snapshot1 = manager.get_snapshot() | ||
| 732 | + original_gears = snapshot1.active_gears["BATCHSIZE"].copy() | ||
| 733 | + | ||
| 734 | + manager.record_event([[20]], [[32]], [[0.375]], [[0.0]], 100.0) | ||
| 735 | + manager.worker.run_once(150.0) | ||
| 736 | + | ||
| 737 | + # old snapshot unchanged | ||
| 738 | + self.assertEqual(snapshot1.active_gears["BATCHSIZE"], original_gears) | ||
| 739 | + manager.shutdown() | ||
| 740 | + | ||
| 741 | + def test_high_concurrent_gear_update_scenarios(self): | ||
| 742 | + """测试高并发场景下的gear更新""" | ||
| 743 | + shape_handling = torch_npu._inductor.NPUShapeHandling( | ||
| 744 | + configs=[ | ||
| 745 | + {"type": "BATCHSIZE", "gears": [16, 32, 64], "dimensions": 0, "indices": [0]}, | ||
| 746 | + ], | ||
| 747 | + adaptive_configs={ | ||
| 748 | + "recent_use_protect_seconds": 1.0, | ||
| 749 | + "min_samples_per_gear": 1, | ||
| 750 | + "add_min_samples": 1, | ||
| 751 | + "min_gear_count_per_type": 2, | ||
| 752 | + "recent_use_protect_seconds": 0.0, | ||
| 753 | + "replace_loss_threshold": 0.20, | ||
| 754 | + }, | ||
| 755 | + ) | ||
| 756 | + manager = shape_handling.adaptive_manager | ||
| 757 | + | ||
| 758 | + def run_update(timestamp): | ||
| 759 | + manager.record_event([[16], [64]], [[32], [64]], [[0.5], [0.0]], [[0.0], [0.0]], 100.0 + timestamp) | ||
| 760 | + manager.worker.run_once(200.0 + timestamp) | ||
| 761 | + | ||
| 762 | + threads = [] | ||
| 763 | + for i in range(5): | ||
| 764 | + thread = threading.Thread(target=run_update, args=(i,)) | ||
| 765 | + threads.append(thread) | ||
| 766 | + thread.start() | ||
| 767 | + | ||
| 768 | + for thread in threads: | ||
| 769 | + thread.join() | ||
| 770 | + | ||
| 771 | + latest_snapshot = manager.get_snapshot() | ||
| 772 | + self.assertIsNotNone(latest_snapshot) | ||
| 773 | + self.assertEqual(sorted(latest_snapshot.active_gears["BATCHSIZE"]), [32, 64]) | ||
| 774 | + manager.shutdown() | ||
| 775 | + | ||
| 776 | + | ||
| 777 | +class TestAdaptiveShapeHandling(TestCase): | ||
| 778 | + def test_npu_shape_handling_creates_snapshot_handler(self): | ||
| 779 | + shape_handling = torch_npu._inductor.NPUShapeHandling( | ||
| 780 | + configs=[ | ||
| 781 | + {"type": "BATCHSIZE", "gears": [16, 32], "dimensions": 0, "indices": [0]}, | ||
| 782 | + {"type": "SEQLEN", "gears": [64, 128], "dimensions": [1], "indices": [0]}, | ||
| 783 | + ], | ||
| 784 | + adaptive_configs={"recent_use_protect_seconds": 1.0}, | ||
| 785 | + ) | ||
| 786 | + manager = shape_handling.adaptive_manager | ||
| 787 | + snapshot = manager.get_snapshot() | ||
| 788 | + self.assertEqual(snapshot.active_gears["BATCHSIZE"], [16, 32]) | ||
| 789 | + self.assertEqual(snapshot.active_gears["SEQLEN"], [64, 128]) | ||
| 790 | + self.assertIsNotNone(snapshot.shape_handling) | ||
| 791 | + | ||
| 792 | + def test_npu_shape_handling_records_event_and_builds_stats(self): | ||
| 793 | + shape_handling = torch_npu._inductor.NPUShapeHandling( | ||
| 794 | + configs=[ | ||
| 795 | + {"type": "BATCHSIZE", "gears": [16, 32], "dimensions": 0, "indices": [0]}, | ||
| 796 | + {"type": "SEQLEN", "gears": [64, 128], "dimensions": [1], "indices": [0]}, | ||
| 797 | + ], | ||
| 798 | + adaptive_configs={"window_seconds": 300.0}, | ||
| 799 | + ) | ||
| 800 | + manager = shape_handling.adaptive_manager | ||
| 801 | + snapshot = manager.get_snapshot() | ||
| 802 | + manager.record_event( | ||
| 803 | + raw_gear_values=[[20], [96]], | ||
| 804 | + mapped_gear_values=[[32], [128]], | ||
| 805 | + pad_ratios=[[0.375], [0.25]], | ||
| 806 | + split_ratios=[[0.0], [0.0]], | ||
| 807 | + event_ts=100.0, | ||
| 808 | + ) | ||
| 809 | + | ||
| 810 | + stats = manager.build_stats_snapshot(100.0) | ||
| 811 | + self.assertIn("BATCHSIZE:32", stats) | ||
| 812 | + self.assertIn("SEQLEN:128", stats) | ||
| 813 | + self.assertEqual(stats["BATCHSIZE:32"]["sample_count"], 1) | ||
| 814 | + self.assertEqual(stats["BATCHSIZE:32"]["pad_sample_count"], 1) | ||
| 815 | + self.assertEqual(stats["BATCHSIZE:32"]["split_sample_count"], 0) | ||
| 816 | + self.assertAlmostEqual(stats["BATCHSIZE:32"]["avg_pad_ratio"], 0.375) | ||
| 817 | + self.assertAlmostEqual(stats["SEQLEN:128"]["avg_pad_ratio"], 0.25) | ||
| 818 | + | ||
| 819 | + def test_npu_shape_handling_records_cleanup_keys_per_gear(self): | ||
| 820 | + shape_handling = torch_npu._inductor.NPUShapeHandling( | ||
| 821 | + configs=[ | ||
| 822 | + {"type": "BATCHSIZE", "gears": [16, 32], "dimensions": 0, "indices": [0]}, | ||
| 823 | + {"type": "SEQLEN", "gears": [64, 128], "dimensions": [1], "indices": [0]}, | ||
| 824 | + ], | ||
| 825 | + adaptive_configs={"window_seconds": 300.0}, | ||
| 826 | + ) | ||
| 827 | + manager = shape_handling.adaptive_manager | ||
| 828 | + snapshot = manager.get_snapshot() | ||
| 829 | + fake_key = 42 # opaque integer key from pool.register() | ||
| 830 | + manager.record_event( | ||
| 831 | + raw_gear_values=[[20], [96]], | ||
| 832 | + mapped_gear_values=[[32], [128]], | ||
| 833 | + pad_ratios=[[0.375], [0.25]], | ||
| 834 | + split_ratios=[[0.0], [0.0]], | ||
| 835 | + event_ts=100.0, | ||
| 836 | + cleanup_key=fake_key, | ||
| 837 | + ) | ||
| 838 | + | ||
| 839 | + self.assertEqual(manager._states["BATCHSIZE:32"].cleanup_keys, {fake_key}) | ||
| 840 | + self.assertEqual(manager._states["SEQLEN:128"].cleanup_keys, {fake_key}) | ||
| 841 | + self.assertIsInstance(next(iter(manager._states["BATCHSIZE:32"].cleanup_keys)), int) | ||
| 842 | + | ||
| 843 | + def test_npu_shape_handling_commit_update_uses_recorded_cleanup_keys(self): | ||
| 844 | + shape_handling = torch_npu._inductor.NPUShapeHandling( | ||
| 845 | + configs=[ | ||
| 846 | + {"type": "BATCHSIZE", "gears": [16, 32], "dimensions": 0, "indices": [0]}, | ||
| 847 | + ], | ||
| 848 | + adaptive_configs={"window_seconds": 300.0}, | ||
| 849 | + ) | ||
| 850 | + manager = shape_handling.adaptive_manager | ||
| 851 | + snapshot = manager.get_snapshot() | ||
| 852 | + fake_key = 42 # opaque integer key | ||
| 853 | + manager.record_event( | ||
| 854 | + raw_gear_values=[[20]], | ||
| 855 | + mapped_gear_values=[[32]], | ||
| 856 | + pad_ratios=[[0.375]], | ||
| 857 | + split_ratios=[[0.0]], | ||
| 858 | + event_ts=100.0, | ||
| 859 | + cleanup_key=fake_key, | ||
| 860 | + ) | ||
| 861 | + | ||
| 862 | + _, removed_keys = manager.commit_update( | ||
| 863 | + configs=[{"type": "BATCHSIZE", "gears": [16], "dimensions": 0, "indices": [0], "policy": "CUSTOM"}], | ||
| 864 | + removed_gears=["BATCHSIZE:32"], | ||
| 865 | + now_ts=101.0, | ||
| 866 | + ) | ||
| 867 | + | ||
| 868 | + self.assertEqual(removed_keys, [fake_key]) | ||
| 869 | + self.assertIsInstance(removed_keys[0], int) | ||
| 870 | + self.assertNotIn("BATCHSIZE:32", manager._states) | ||
| 871 | + | ||
| 872 | + def test_npu_shape_handling_builds_separate_pad_and_split_sample_counts(self): | ||
| 873 | + shape_handling = torch_npu._inductor.NPUShapeHandling( | ||
| 874 | + configs=[ | ||
| 875 | + {"type": "BATCHSIZE", "gears": [16, 32, 64], "dimensions": 0, "indices": [0]}, | ||
| 876 | + ], | ||
| 877 | + adaptive_configs={"window_seconds": 300.0}, | ||
| 878 | + ) | ||
| 879 | + manager = shape_handling.adaptive_manager | ||
| 880 | + snapshot = manager.get_snapshot() | ||
| 881 | + manager.record_event([[20]], [[32]], [[0.375]], [[0.0]], 100.0) | ||
| 882 | + manager.record_event([[80]], [[64]], [[0.0]], [[0.20]], 101.0) | ||
| 883 | + | ||
| 884 | + stats = manager.build_stats_snapshot(101.0) | ||
| 885 | + self.assertEqual(stats["BATCHSIZE:32"]["pad_sample_count"], 1) | ||
| 886 | + self.assertEqual(stats["BATCHSIZE:32"]["split_sample_count"], 0) | ||
| 887 | + self.assertEqual(stats["BATCHSIZE:64"]["pad_sample_count"], 0) | ||
| 888 | + self.assertEqual(stats["BATCHSIZE:64"]["split_sample_count"], 1) | ||
| 889 | + | ||
| 890 | + def test_npu_shape_handling_builds_two_dim_stats_per_dimension(self): | ||
| 891 | + shape_handling = torch_npu._inductor.NPUShapeHandling( | ||
| 892 | + configs=[ | ||
| 893 | + {"type": "BATCHSIZE", "gears": [16, 32], "dimensions": 0, "indices": [0]}, | ||
| 894 | + {"type": "SEQLEN", "gears": [64, 128], "dimensions": [1], "indices": [0]}, | ||
| 895 | + ], | ||
| 896 | + adaptive_configs={"window_seconds": 300.0}, | ||
| 897 | + ) | ||
| 898 | + manager = shape_handling.adaptive_manager | ||
| 899 | + snapshot = manager.get_snapshot() | ||
| 900 | + manager.record_event([[20], [96]], [[32], [128]], [[0.375], [0.25]], [[0.0], [0.0]], 100.0) | ||
| 901 | + manager.record_event([[24], [112]], [[32], [128]], [[0.25], [0.125]], [[0.0], [0.0]], 101.0) | ||
| 902 | + | ||
| 903 | + stats = manager.build_stats_snapshot(101.0) | ||
| 904 | + self.assertEqual(stats["BATCHSIZE:32"]["sample_count"], 2) | ||
| 905 | + self.assertEqual(stats["BATCHSIZE:32"]["raw_samples"], [20, 24]) | ||
| 906 | + self.assertEqual(stats["SEQLEN:128"]["sample_count"], 2) | ||
| 907 | + self.assertEqual(stats["SEQLEN:128"]["raw_samples"], [96, 112]) | ||
| 908 | + | ||
| 909 | + def test_npu_shape_handling_recent_use_protect_skips_recent_hit_gear(self): | ||
| 910 | + shape_handling = torch_npu._inductor.NPUShapeHandling( | ||
| 911 | + configs=[ | ||
| 912 | + {"type": "BATCHSIZE", "gears": [8, 16, 32], "dimensions": 0, "indices": [0]}, | ||
| 913 | + ], | ||
| 914 | + adaptive_configs={ | ||
| 915 | + "recent_use_protect_seconds": 60.0, | ||
| 916 | + "min_gear_count_per_type": 1, | ||
| 917 | + }, | ||
| 918 | + ) | ||
| 919 | + manager = shape_handling.adaptive_manager | ||
| 920 | + snapshot = manager.get_snapshot() | ||
| 921 | + manager.record_event([[20]], [[16]], [[0.375]], [[0.0]], 100.0) | ||
| 922 | + | ||
| 923 | + stats = manager.build_stats_snapshot(120.0) | ||
| 924 | + breakdowns = manager.scorer.build_score_breakdown(manager.get_snapshot(), stats) | ||
| 925 | + candidates = manager.worker.build_eviction_candidates( | ||
| 926 | + breakdowns, | ||
| 927 | + manager.get_snapshot(), | ||
| 928 | + stats, | ||
| 929 | + 120.0, | ||
| 930 | + ) | ||
| 931 | + self.assertEqual(candidates, {"BATCHSIZE": "BATCHSIZE:8"}) | ||
| 932 | + | ||
| 933 | + def test_npu_shape_handling_eviction_protection_skips_gear(self): | ||
| 934 | + shape_handling = torch_npu._inductor.NPUShapeHandling( | ||
| 935 | + configs=[ | ||
| 936 | + {"type": "BATCHSIZE", "gears": [8, 16, 32], "dimensions": 0, "indices": [0]}, | ||
| 937 | + ], | ||
| 938 | + adaptive_configs={ | ||
| 939 | + "recent_use_protect_seconds": 300.0, | ||
| 940 | + "min_gear_count_per_type": 1, | ||
| 941 | + }, | ||
| 942 | + ) | ||
| 943 | + manager = shape_handling.adaptive_manager | ||
| 944 | + snapshot = manager.get_snapshot() | ||
| 945 | + manager.record_event([[20]], [[32]], [[0.375]], [[0.0]], 0.0) | ||
| 946 | + | ||
| 947 | + manager.protect_gear_from_eviction("BATCHSIZE:16", 500.0) | ||
| 948 | + | ||
| 949 | + stats = manager.build_stats_snapshot(500.0) | ||
| 950 | + breakdowns = manager.scorer.build_score_breakdown(manager.get_snapshot(), stats) | ||
| 951 | + candidates = manager.worker.build_eviction_candidates( | ||
| 952 | + breakdowns, | ||
| 953 | + manager.get_snapshot(), | ||
| 954 | + stats, | ||
| 955 | + 500.0, | ||
| 956 | + ) | ||
| 957 | + self.assertEqual(candidates, {"BATCHSIZE": "BATCHSIZE:8"}) | ||
| 958 | + | ||
| 959 | + def test_npu_shape_handling_zero_usage_gears_are_eviction_candidates(self): | ||
| 960 | + shape_handling = torch_npu._inductor.NPUShapeHandling( | ||
| 961 | + configs=[ | ||
| 962 | + {"type": "BATCHSIZE", "gears": [16, 32], "dimensions": 0, "indices": [0]}, | ||
| 963 | + ], | ||
| 964 | + adaptive_configs={ | ||
| 965 | + "recent_use_protect_seconds": 0.0, | ||
| 966 | + "min_gear_count_per_type": 1, | ||
| 967 | + }, | ||
| 968 | + ) | ||
| 969 | + manager = shape_handling.adaptive_manager | ||
| 970 | + snapshot = manager.get_snapshot() | ||
| 971 | + manager.record_event([[30]], [[32]], [[0.0625]], [[0.0]], 100.0) | ||
| 972 | + | ||
| 973 | + stats = manager.build_stats_snapshot(120.0) | ||
| 974 | + breakdowns = manager.scorer.build_score_breakdown(manager.get_snapshot(), stats) | ||
| 975 | + candidates = manager.worker.build_eviction_candidates( | ||
| 976 | + breakdowns, | ||
| 977 | + manager.get_snapshot(), | ||
| 978 | + stats, | ||
| 979 | + 120.0, | ||
| 980 | + ) | ||
| 981 | + # only unused gear is candidate | ||
| 982 | + self.assertEqual(candidates, {"BATCHSIZE": "BATCHSIZE:16"}) | ||
| 983 | + | ||
| 984 | + def test_npu_shape_handling_update_adds_new_gear(self): | ||
| 985 | + shape_handling = torch_npu._inductor.NPUShapeHandling( | ||
| 986 | + configs=[ | ||
| 987 | + {"type": "BATCHSIZE", "gears": [16, 32], "dimensions": 0, "indices": [0]}, | ||
| 988 | + {"type": "SEQLEN", "gears": [64], "dimensions": [1], "indices": [0]}, | ||
| 989 | + ], | ||
| 990 | + adaptive_configs={ | ||
| 991 | + "recent_use_protect_seconds": 1.0, | ||
| 992 | + "min_samples_per_gear": 2, | ||
| 993 | + "add_min_samples": 2, | ||
| 994 | + "pad_add_threshold": 0.20, | ||
| 995 | + "recent_use_protect_seconds": 0.0, | ||
| 996 | + }, | ||
| 997 | + ) | ||
| 998 | + manager = shape_handling.adaptive_manager | ||
| 999 | + snapshot = manager.get_snapshot() | ||
| 1000 | + manager.record_event([[20], [96]], [[32], [64]], [[0.375], [0.50]], [[0.0], [0.0]], 100.0) | ||
| 1001 | + manager.record_event([[20], [96]], [[32], [64]], [[0.375], [0.50]], [[0.0], [0.0]], 101.0) | ||
| 1002 | + | ||
| 1003 | + manager.worker.run_once(150.0) | ||
| 1004 | + latest_snapshot = manager.get_snapshot() | ||
| 1005 | + # pad-driven gear added | ||
| 1006 | + self.assertIn(96, latest_snapshot.active_gears["SEQLEN"]) | ||
| 1007 | + | ||
| 1008 | + def test_npu_shape_handling_new_zero_usage_gear_respects_recent_create_protection(self): | ||
| 1009 | + shape_handling = torch_npu._inductor.NPUShapeHandling( | ||
| 1010 | + configs=[ | ||
| 1011 | + {"type": "BATCHSIZE", "gears": [16, 32, 64], "min_size": 1, "dimensions": 0, "indices": [0]}, | ||
| 1012 | + ], | ||
| 1013 | + adaptive_configs={ | ||
| 1014 | + "recent_use_protect_seconds": 1.0, | ||
| 1015 | + "add_min_samples": 2, | ||
| 1016 | + "pad_add_threshold": 0.20, | ||
| 1017 | + "recent_use_protect_seconds": 60.0, | ||
| 1018 | + "min_gear_count_per_type": 1, | ||
| 1019 | + }, | ||
| 1020 | + ) | ||
| 1021 | + manager = shape_handling.adaptive_manager | ||
| 1022 | + snapshot = manager.get_snapshot() | ||
| 1023 | + manager.record_event([[20]], [[32]], [[0.375]], [[0.0]], 10.0) | ||
| 1024 | + manager.record_event([[20]], [[32]], [[0.375]], [[0.0]], 11.0) | ||
| 1025 | + | ||
| 1026 | + manager.worker.run_once(100.0) | ||
| 1027 | + | ||
| 1028 | + latest_snapshot = manager.get_snapshot() | ||
| 1029 | + # gear 20 was added | ||
| 1030 | + self.assertIn(20, latest_snapshot.active_gears["BATCHSIZE"]) | ||
| 1031 | + | ||
| 1032 | + stats = manager.build_stats_snapshot(120.0) | ||
| 1033 | + breakdowns = manager.scorer.build_score_breakdown(latest_snapshot, stats) | ||
| 1034 | + candidates = manager.worker.build_eviction_candidates( | ||
| 1035 | + breakdowns, latest_snapshot, stats, 120.0, | ||
| 1036 | + ) | ||
| 1037 | + # new gear has zero hits | ||
| 1038 | + self.assertEqual(stats["BATCHSIZE:20"]["sample_count"], 0) | ||
| 1039 | + # creation timestamp preserved | ||
| 1040 | + self.assertEqual(stats["BATCHSIZE:20"]["created_ts"], 100.0) | ||
| 1041 | + # gear 32 evictable, gear 20 protected | ||
| 1042 | + self.assertEqual(candidates, {"BATCHSIZE": "BATCHSIZE:32"}) | ||
| 1043 | + | ||
| 1044 | + def test_npu_shape_handling_update_adds_split_driven_gear(self): | ||
| 1045 | + shape_handling = torch_npu._inductor.NPUShapeHandling( | ||
| 1046 | + configs=[ | ||
| 1047 | + {"type": "BATCHSIZE", "gears": [64], "dimensions": 0, "indices": [0]}, | ||
| 1048 | + ], | ||
| 1049 | + adaptive_configs={ | ||
| 1050 | + "recent_use_protect_seconds": 1.0, | ||
| 1051 | + "add_min_samples": 2, | ||
| 1052 | + "pad_add_threshold": 0.90, | ||
| 1053 | + "split_add_threshold": 0.10, | ||
| 1054 | + "recent_use_protect_seconds": 0.0, | ||
| 1055 | + }, | ||
| 1056 | + ) | ||
| 1057 | + manager = shape_handling.adaptive_manager | ||
| 1058 | + snapshot = manager.get_snapshot() | ||
| 1059 | + manager.record_event([[96]], [[64]], [[0.0]], [[0.34]], 100.0) | ||
| 1060 | + manager.record_event([[96]], [[64]], [[0.0]], [[0.34]], 101.0) | ||
| 1061 | + | ||
| 1062 | + manager.worker.run_once(150.0) | ||
| 1063 | + latest_snapshot = manager.get_snapshot() | ||
| 1064 | + # split-driven gear added at median | ||
| 1065 | + self.assertIn(96, latest_snapshot.active_gears["BATCHSIZE"]) | ||
| 1066 | + | ||
| 1067 | + def test_npu_shape_handling_two_dim_addition_candidates_are_built_per_dimension(self): | ||
| 1068 | + shape_handling = torch_npu._inductor.NPUShapeHandling( | ||
| 1069 | + configs=[ | ||
| 1070 | + {"type": "BATCHSIZE", "gears": [8], "dimensions": 0, "indices": [0]}, | ||
| 1071 | + {"type": "SEQLEN", "gears": [32], "dimensions": [1], "indices": [0]}, | ||
| 1072 | + ], | ||
| 1073 | + adaptive_configs={ | ||
| 1074 | + "recent_use_protect_seconds": 1.0, | ||
| 1075 | + "add_min_samples": 2, | ||
| 1076 | + "split_add_threshold": 0.10, | ||
| 1077 | + "recent_use_protect_seconds": 0.0, | ||
| 1078 | + }, | ||
| 1079 | + ) | ||
| 1080 | + manager = shape_handling.adaptive_manager | ||
| 1081 | + snapshot = manager.get_snapshot() | ||
| 1082 | + manager.record_event([[16], [192]], [[8], [32]], [[0.0], [0.0]], [[0.50], [0.83]], 100.0) | ||
| 1083 | + manager.record_event([[16], [192]], [[8], [32]], [[0.0], [0.0]], [[0.50], [0.83]], 101.0) | ||
| 1084 | + manager.record_event([[48], [64]], [[8], [32]], [[0.0], [0.0]], [[0.83], [0.50]], 102.0) | ||
| 1085 | + manager.record_event([[48], [64]], [[8], [32]], [[0.0], [0.0]], [[0.83], [0.50]], 103.0) | ||
| 1086 | + | ||
| 1087 | + stats = manager.build_stats_snapshot(103.0) | ||
| 1088 | + candidates = manager.worker.build_addition_candidates( | ||
| 1089 | + manager.get_snapshot(), | ||
| 1090 | + stats, | ||
| 1091 | + ) | ||
| 1092 | + result = {shape_type: value for _, _, shape_type, value in candidates} | ||
| 1093 | + self.assertEqual(result, {"BATCHSIZE": 32, "SEQLEN": 128}) | ||
| 1094 | + | ||
| 1095 | + def test_npu_shape_handling_update_evicts_low_value_gear(self): | ||
| 1096 | + shape_handling = torch_npu._inductor.NPUShapeHandling( | ||
| 1097 | + configs=[ | ||
| 1098 | + {"type": "BATCHSIZE", "gears": [8, 16, 32], "dimensions": 0, "indices": [0]}, | ||
| 1099 | + {"type": "SEQLEN", "gears": [64], "dimensions": [1], "indices": [0]}, | ||
| 1100 | + ], | ||
| 1101 | + adaptive_configs={ | ||
| 1102 | + "recent_use_protect_seconds": 1.0, | ||
| 1103 | + "min_samples_per_gear": 1, | ||
| 1104 | + "min_gear_count_per_type": 2, | ||
| 1105 | + "recent_use_protect_seconds": 0.0, | ||
| 1106 | + "replace_loss_threshold": 1.0, | ||
| 1107 | + }, | ||
| 1108 | + ) | ||
| 1109 | + manager = shape_handling.adaptive_manager | ||
| 1110 | + snapshot = manager.get_snapshot() | ||
| 1111 | + manager.record_event([[8], [64]], [[8], [64]], [[0.0], [0.0]], [[0.0], [0.0]], 10.0) | ||
| 1112 | + manager.record_event([[8], [64]], [[8], [64]], [[0.0], [0.0]], [[0.0], [0.0]], 11.0) | ||
| 1113 | + manager.record_event([[10], [64]], [[16], [64]], [[0.375], [0.0]], [[0.0], [0.0]], 12.0) | ||
| 1114 | + manager.record_event([[32], [64]], [[32], [64]], [[0.0], [0.0]], [[0.0], [0.0]], 13.0) | ||
| 1115 | + | ||
| 1116 | + manager.worker.run_once(20.0) | ||
| 1117 | + latest_snapshot = manager.get_snapshot() | ||
| 1118 | + self.assertNotIn(16, latest_snapshot.active_gears["BATCHSIZE"]) | ||
| 1119 | + self.assertIn(8, latest_snapshot.active_gears["BATCHSIZE"]) | ||
| 1120 | + self.assertIn(32, latest_snapshot.active_gears["BATCHSIZE"]) | ||
| 1121 | + | ||
| 1122 | + def test_npu_shape_handling_two_dim_evicts_per_dimension(self): | ||
| 1123 | + shape_handling = torch_npu._inductor.NPUShapeHandling( | ||
| 1124 | + configs=[ | ||
| 1125 | + {"type": "BATCHSIZE", "gears": [8, 16, 32], "dimensions": 0, "indices": [0]}, | ||
| 1126 | + {"type": "SEQLEN", "gears": [32, 64, 128], "dimensions": [1], "indices": [0]}, | ||
| 1127 | + ], | ||
| 1128 | + adaptive_configs={ | ||
| 1129 | + "recent_use_protect_seconds": 1.0, | ||
| 1130 | + "min_samples_per_gear": 1, | ||
| 1131 | + "min_gear_count_per_type": 2, | ||
| 1132 | + "add_min_samples": 10, | ||
| 1133 | + "recent_use_protect_seconds": 0.0, | ||
| 1134 | + "replace_loss_threshold": 1.0, | ||
| 1135 | + }, | ||
| 1136 | + ) | ||
| 1137 | + manager = shape_handling.adaptive_manager | ||
| 1138 | + snapshot = manager.get_snapshot() | ||
| 1139 | + manager.record_event([[8], [64]], [[8], [64]], [[0.0], [0.0]], [[0.0], [0.0]], 18.0) | ||
| 1140 | + manager.record_event([[10], [96]], [[16], [128]], [[0.375], [0.25]], [[0.0], [0.0]], 19.0) | ||
| 1141 | + manager.record_event([[32], [64]], [[32], [64]], [[0.0], [0.0]], [[0.0], [0.0]], 19.0) | ||
| 1142 | + | ||
| 1143 | + manager.worker.run_once(20.0) | ||
| 1144 | + latest_snapshot = manager.get_snapshot() | ||
| 1145 | + self.assertEqual(latest_snapshot.active_gears["BATCHSIZE"], [8, 32]) | ||
| 1146 | + self.assertEqual(latest_snapshot.active_gears["SEQLEN"], [64, 128]) | ||
| 1147 | + | ||
| 1148 | + def test_npu_shape_handling_resource_pressure_triggers_update(self): | ||
| 1149 | + shape_handling = torch_npu._inductor.NPUShapeHandling( | ||
| 1150 | + configs=[ | ||
| 1151 | + {"type": "BATCHSIZE", "gears": [16, 32], "dimensions": 0, "indices": [0]}, | ||
| 1152 | + ], | ||
| 1153 | + adaptive_configs={ | ||
| 1154 | + "recent_use_protect_seconds": 300.0, | ||
| 1155 | + "device_memory_usage_threshold_ratio": 0.80, | ||
| 1156 | + }, | ||
| 1157 | + ) | ||
| 1158 | + manager = shape_handling.adaptive_manager | ||
| 1159 | + # Memory usage ratio = 1.0 - 20/100 = 0.80 >= threshold → high pressure | ||
| 1160 | + with mock.patch("torch_npu._inductor.adaptive_gears.torch.npu.mem_get_info", return_value=(20, 100)): | ||
| 1161 | + budget = manager.build_resource_budget() | ||
| 1162 | + self.assertTrue(budget["device_memory_usage_high"]) | ||
| 1163 | + # Memory usage ratio = 1.0 - 70/100 = 0.30 < threshold → no pressure | ||
| 1164 | + with mock.patch("torch_npu._inductor.adaptive_gears.torch.npu.mem_get_info", return_value=(70, 100)): | ||
| 1165 | + budget = manager.build_resource_budget() | ||
| 1166 | + self.assertFalse(budget["device_memory_usage_high"]) | ||
| 1167 | + | ||
| 1168 | + def test_npu_shape_handling_resource_budget_uses_device_ratio(self): | ||
| 1169 | + shape_handling = torch_npu._inductor.NPUShapeHandling( | ||
| 1170 | + configs=[ | ||
| 1171 | + {"type": "BATCHSIZE", "gears": [16, 32], "dimensions": 0, "indices": [0]}, | ||
| 1172 | + ], | ||
| 1173 | + adaptive_configs={ | ||
| 1174 | + "device_memory_usage_threshold_ratio": 0.60, | ||
| 1175 | + }, | ||
| 1176 | + ) | ||
| 1177 | + manager = shape_handling.adaptive_manager | ||
| 1178 | + with mock.patch("torch_npu._inductor.adaptive_gears.torch.npu.mem_get_info", return_value=(40, 100)): | ||
| 1179 | + budget = manager.build_resource_budget() | ||
| 1180 | + | ||
| 1181 | + self.assertAlmostEqual(budget["device_memory_usage_ratio"], 0.60) | ||
| 1182 | + self.assertEqual(budget["device_memory_usage_threshold_ratio"], 0.60) | ||
| 1183 | + self.assertTrue(budget["device_memory_usage_high"]) | ||
| 1184 | + | ||
| 1185 | + def test_npu_shape_handling_device_pressure_blocks_direct_add(self): | ||
| 1186 | + shape_handling = torch_npu._inductor.NPUShapeHandling( | ||
| 1187 | + configs=[ | ||
| 1188 | + {"type": "BATCHSIZE", "gears": [64], "dimensions": 0, "indices": [0]}, | ||
| 1189 | + ], | ||
| 1190 | + adaptive_configs={ | ||
| 1191 | + "recent_use_protect_seconds": 1.0, | ||
| 1192 | + "add_min_samples": 2, | ||
| 1193 | + "pad_add_threshold": 0.20, | ||
| 1194 | + "device_memory_usage_threshold_ratio": 0.50, | ||
| 1195 | + "min_gear_count_per_type": 1, | ||
| 1196 | + "recent_use_protect_seconds": 0.0, | ||
| 1197 | + }, | ||
| 1198 | + ) | ||
| 1199 | + manager = shape_handling.adaptive_manager | ||
| 1200 | + snapshot = manager.get_snapshot() | ||
| 1201 | + manager.record_event([[40]], [[64]], [[0.375]], [[0.0]], 100.0) | ||
| 1202 | + manager.record_event([[40]], [[64]], [[0.375]], [[0.0]], 101.0) | ||
| 1203 | + | ||
| 1204 | + with mock.patch("torch_npu._inductor.adaptive_gears.torch.npu.mem_get_info", return_value=(40, 100)): | ||
| 1205 | + manager.worker.run_once(150.0) | ||
| 1206 | + latest_snapshot = manager.get_snapshot() | ||
| 1207 | + self.assertEqual(latest_snapshot.active_gears["BATCHSIZE"], [64]) | ||
| 1208 | + | ||
| 1209 | + def test_npu_shape_handling_max_gear_guard_blocks_unsafe_delete(self): | ||
| 1210 | + shape_handling = torch_npu._inductor.NPUShapeHandling( | ||
| 1211 | + configs=[ | ||
| 1212 | + {"type": "BATCHSIZE", "gears": [32, 64], "dimensions": 0, "indices": [0]}, | ||
| 1213 | + ], | ||
| 1214 | + adaptive_configs={ | ||
| 1215 | + "recent_use_protect_seconds": 0.0, | ||
| 1216 | + "replace_loss_threshold": 1.0, | ||
| 1217 | + "min_gear_count_per_type": 1, | ||
| 1218 | + }, | ||
| 1219 | + ) | ||
| 1220 | + manager = shape_handling.adaptive_manager | ||
| 1221 | + snapshot = manager.get_snapshot() | ||
| 1222 | + manager.record_event([[40]], [[64]], [[0.375]], [[0.0]], 100.0) | ||
| 1223 | + | ||
| 1224 | + stats = manager.build_stats_snapshot(100.0) | ||
| 1225 | + breakdowns = manager.scorer.build_score_breakdown(manager.get_snapshot(), stats) | ||
| 1226 | + candidates = manager.worker.build_eviction_candidates( | ||
| 1227 | + breakdowns, | ||
| 1228 | + manager.get_snapshot(), | ||
| 1229 | + stats, | ||
| 1230 | + 100.0, | ||
| 1231 | + ) | ||
| 1232 | + self.assertNotIn("BATCHSIZE:64", candidates.values()) | ||
| 523 | 1233 | ||
| 524 | class TestDynamicShapeCompile(TestCase): | 1234 | class TestDynamicShapeCompile(TestCase): |
| 525 | def test_npu_dynamic_shape_reuse_with_no_bucket(self): | 1235 | def test_npu_dynamic_shape_reuse_with_no_bucket(self): |
| @@ -807,10 +1517,322 @@ class TestUnifiedCopy(TestCase): | |||
| 807 | self.assertIs(copied, obj) | 1517 | self.assertIs(copied, obj) |
| 808 | 1518 | ||
| 809 | 1519 | ||
| 1520 | +# --------------------------------------------------------------------------- | ||
| 1521 | +# ST: Adaptive Gear end-to-end integration tests | ||
| 1522 | +# --------------------------------------------------------------------------- | ||
| 1523 | + | ||
| 1524 | +_ST_ADAPTIVE_CONFIGS = { | ||
| 1525 | + "window_seconds": 300.0, | ||
| 1526 | + "recent_use_protect_seconds": 0, | ||
| 1527 | + "pad_add_threshold": 0.20, | ||
| 1528 | + "split_add_threshold": 0.20, | ||
| 1529 | + "add_min_samples": 2, | ||
| 1530 | + "min_samples_per_gear": 1, | ||
| 1531 | + "min_gear_count_per_type": 2, | ||
| 1532 | + "replace_loss_threshold": 1.0, | ||
| 1533 | + "update_interval_seconds": 9999.0, | ||
| 1534 | +} | ||
| 1535 | + | ||
| 1536 | +_ST_DAEMON_ADAPTIVE_CONFIGS = dict(_ST_ADAPTIVE_CONFIGS) | ||
| 1537 | +_ST_DAEMON_ADAPTIVE_CONFIGS["update_interval_seconds"] = 0.1 | ||
| 1538 | + | ||
| 1539 | + | ||
| 1540 | +def _make_adaptive_options(shape_configs, adaptive_configs=None): | ||
| 1541 | + opts = { | ||
| 1542 | + "enable_shape_handling": True, | ||
| 1543 | + "shape_handling_configs": shape_configs, | ||
| 1544 | + } | ||
| 1545 | + if adaptive_configs is not None: | ||
| 1546 | + opts["shape_handling_dict"] = {"adaptive_gears": adaptive_configs} | ||
| 1547 | + return opts | ||
| 1548 | + | ||
| 1549 | + | ||
| 1550 | +def _run_model(compiled_fn, shape): | ||
| 1551 | + A = torch.randn(shape, device=device) | ||
| 1552 | + B = torch.randn(shape, device=device) | ||
| 1553 | + out = compiled_fn(A, B) | ||
| 1554 | + return out, A, B | ||
| 1555 | + | ||
| 1556 | + | ||
| 1557 | +class TestAdaptiveGearsCompileST(TestCase): | ||
| 1558 | + | ||
| 1559 | + def setUp(self): | ||
| 1560 | + torch._dynamo.reset() | ||
| 1561 | + self._captured_managers = [] | ||
| 1562 | + self._original_sh_init = shape_handling_module.NPUShapeHandling.__init__ | ||
| 1563 | + | ||
| 1564 | + test_self = self | ||
| 1565 | + | ||
| 1566 | + def capturing_init(sh_self, *args, **kwargs): | ||
| 1567 | + test_self._original_sh_init(sh_self, *args, **kwargs) | ||
| 1568 | + if sh_self.adaptive_manager is not None: | ||
| 1569 | + test_self._captured_managers.append(sh_self.adaptive_manager) | ||
| 1570 | + | ||
| 1571 | + shape_handling_module.NPUShapeHandling.__init__ = capturing_init | ||
| 1572 | + | ||
| 1573 | + def tearDown(self): | ||
| 1574 | + shape_handling_module.NPUShapeHandling.__init__ = self._original_sh_init | ||
| 1575 | + for mgr in self._captured_managers: | ||
| 1576 | + try: | ||
| 1577 | + mgr.shutdown() | ||
| 1578 | + except Exception: | ||
| 1579 | + pass | ||
| 1580 | + self._captured_managers.clear() | ||
| 1581 | + torch._dynamo.reset() | ||
| 1582 | + | ||
| 1583 | + def _get_manager(self): | ||
| 1584 | + self.assertTrue( | ||
| 1585 | + len(self._captured_managers) > 0, | ||
| 1586 | + "No AdaptiveGearRuntime was captured — torch.compile did not create one", | ||
| 1587 | + ) | ||
| 1588 | + return self._captured_managers[-1] | ||
| 1589 | + | ||
| 1590 | + def _stop_daemon(self, manager): | ||
| 1591 | + manager._shutdown_event.set() | ||
| 1592 | + manager._worker_thread.join(timeout=2.0) | ||
| 1593 | + | ||
| 1594 | + def test_adaptive_gears_basic_compile_and_compute(self): | ||
| 1595 | + shape_configs = [ | ||
| 1596 | + {"type": "BATCHSIZE", "gears": [16, 32], "dimensions": 0, "indices": [0, 1]}, | ||
| 1597 | + ] | ||
| 1598 | + options = _make_adaptive_options(shape_configs, _ST_ADAPTIVE_CONFIGS) | ||
| 1599 | + | ||
| 1600 | + compiled_fn = torch.compile( | ||
| 1601 | + model_fn, | ||
| 1602 | + backend="inductor", | ||
| 1603 | + dynamic=False, | ||
| 1604 | + options=options, | ||
| 1605 | + ) | ||
| 1606 | + | ||
| 1607 | + for shape in [(8, 32), (24, 32), (10, 32)]: | ||
| 1608 | + out, A, B = _run_model(compiled_fn, shape) | ||
| 1609 | + self.assertTrue( | ||
| 1610 | + torch.allclose(out, A + B), | ||
| 1611 | + f"Output mismatch for shape {shape}", | ||
| 1612 | + ) | ||
| 1613 | + | ||
| 1614 | + manager = self._get_manager() | ||
| 1615 | + | ||
| 1616 | + self.assertTrue( | ||
| 1617 | + manager._worker_thread.is_alive(), | ||
| 1618 | + "Daemon worker thread should be alive", | ||
| 1619 | + ) | ||
| 1620 | + | ||
| 1621 | + def test_adaptive_gears_event_recording_and_stats(self): | ||
| 1622 | + shape_configs = [ | ||
| 1623 | + {"type": "BATCHSIZE", "gears": [16, 32], "dimensions": 0, "indices": [0, 1]}, | ||
| 1624 | + ] | ||
| 1625 | + options = _make_adaptive_options(shape_configs, _ST_ADAPTIVE_CONFIGS) | ||
| 1626 | + | ||
| 1627 | + compiled_fn = torch.compile( | ||
| 1628 | + model_fn, | ||
| 1629 | + backend="inductor", | ||
| 1630 | + dynamic=False, | ||
| 1631 | + options=options, | ||
| 1632 | + ) | ||
| 1633 | + manager = self._get_manager() | ||
| 1634 | + | ||
| 1635 | + for _ in range(3): | ||
| 1636 | + out, A, B = _run_model(compiled_fn, (24, 32)) | ||
| 1637 | + self.assertTrue(torch.allclose(out, A + B)) | ||
| 1638 | + | ||
| 1639 | + stats = manager.build_stats_snapshot(time.time()) | ||
| 1640 | + | ||
| 1641 | + self.assertIn("BATCHSIZE:32", stats) | ||
| 1642 | + self.assertEqual( | ||
| 1643 | + stats["BATCHSIZE:32"]["sample_count"], 6, | ||
| 1644 | + "Exactly 6 samples should be recorded for gear 32 (3 calls × 2 tensors)", | ||
| 1645 | + ) | ||
| 1646 | + self.assertEqual( | ||
| 1647 | + stats["BATCHSIZE:32"]["pad_sample_count"], 6, | ||
| 1648 | + "All 6 events should be classified as padding", | ||
| 1649 | + ) | ||
| 1650 | + self.assertAlmostEqual( | ||
| 1651 | + stats["BATCHSIZE:32"]["avg_pad_ratio"], 0.25, places=2, | ||
| 1652 | + msg="avg_pad_ratio should be (32-24)/32 = 0.25", | ||
| 1653 | + ) | ||
| 1654 | + self.assertEqual( | ||
| 1655 | + stats["BATCHSIZE:32"]["raw_samples"], [24, 24, 24, 24, 24, 24], | ||
| 1656 | + "raw_samples should have 6 entries (3 calls × 2 tensors)", | ||
| 1657 | + ) | ||
| 1658 | + | ||
| 1659 | + def test_adaptive_gears_gear_eviction_synchronous(self): | ||
| 1660 | + shape_configs = [ | ||
| 1661 | + {"type": "BATCHSIZE", "gears": [16, 32, 64], "dimensions": 0, "indices": [0, 1]}, | ||
| 1662 | + ] | ||
| 1663 | + options = _make_adaptive_options(shape_configs, _ST_ADAPTIVE_CONFIGS) | ||
| 1664 | + | ||
| 1665 | + compiled_fn = torch.compile( | ||
| 1666 | + model_fn, | ||
| 1667 | + backend="inductor", | ||
| 1668 | + dynamic=False, | ||
| 1669 | + options=options, | ||
| 1670 | + ) | ||
| 1671 | + manager = self._get_manager() | ||
| 1672 | + self._stop_daemon(manager) | ||
| 1673 | + | ||
| 1674 | + for _ in range(5): | ||
| 1675 | + out, A, B = _run_model(compiled_fn, (24, 32)) | ||
| 1676 | + self.assertTrue(torch.allclose(out, A + B)) | ||
| 1677 | + | ||
| 1678 | + manager.worker.run_once(time.time()) | ||
| 1679 | + | ||
| 1680 | + snapshot_after = manager.get_snapshot() | ||
| 1681 | + gears_after = sorted(snapshot_after.active_gears["BATCHSIZE"]) | ||
| 1682 | + | ||
| 1683 | + # Gear 16 evicted (zero hits) + gear 24 added (pad_ratio=0.25 > threshold 0.20) | ||
| 1684 | + self.assertEqual( | ||
| 1685 | + gears_after, [24, 32, 64], | ||
| 1686 | + f"Expected [24, 32, 64] after eviction+addition, got {gears_after}", | ||
| 1687 | + ) | ||
| 1688 | + | ||
| 1689 | + out, A, B = _run_model(compiled_fn, (8, 32)) | ||
| 1690 | + self.assertTrue( | ||
| 1691 | + torch.allclose(out, A + B), | ||
| 1692 | + "Computation should be correct after gear eviction", | ||
| 1693 | + ) | ||
| 1694 | + | ||
| 1695 | + def test_adaptive_gears_gear_addition_synchronous(self): | ||
| 1696 | + shape_configs = [ | ||
| 1697 | + {"type": "BATCHSIZE", "gears": [64], "dimensions": 0, "indices": [0, 1]}, | ||
| 1698 | + ] | ||
| 1699 | + add_configs = dict(_ST_ADAPTIVE_CONFIGS) | ||
| 1700 | + add_configs["min_gear_count_per_type"] = 1 | ||
| 1701 | + options = _make_adaptive_options(shape_configs, add_configs) | ||
| 1702 | + | ||
| 1703 | + compiled_fn = torch.compile( | ||
| 1704 | + model_fn, | ||
| 1705 | + backend="inductor", | ||
| 1706 | + dynamic=False, | ||
| 1707 | + options=options, | ||
| 1708 | + ) | ||
| 1709 | + manager = self._get_manager() | ||
| 1710 | + self._stop_daemon(manager) | ||
| 1711 | + | ||
| 1712 | + for _ in range(3): | ||
| 1713 | + out, A, B = _run_model(compiled_fn, (20, 32)) | ||
| 1714 | + self.assertTrue(torch.allclose(out, A + B)) | ||
| 1715 | + | ||
| 1716 | + manager.worker.run_once(time.time()) | ||
| 1717 | + | ||
| 1718 | + snapshot_after = manager.get_snapshot() | ||
| 1719 | + gears_after = snapshot_after.active_gears["BATCHSIZE"] | ||
| 1720 | + | ||
| 1721 | + self.assertEqual( | ||
| 1722 | + len(gears_after), 2, | ||
| 1723 | + f"A new gear should be added, got {gears_after}", | ||
| 1724 | + ) | ||
| 1725 | + self.assertIn( | ||
| 1726 | + 20, gears_after, | ||
| 1727 | + f"New gear 20 (median of raw samples) should be added, got {gears_after}", | ||
| 1728 | + ) | ||
| 1729 | + | ||
| 1730 | + out, A, B = _run_model(compiled_fn, (20, 32)) | ||
| 1731 | + self.assertTrue( | ||
| 1732 | + torch.allclose(out, A + B), | ||
| 1733 | + "Computation should be correct after gear addition", | ||
| 1734 | + ) | ||
| 1735 | + | ||
| 1736 | + def test_adaptive_gears_graph_cleanup_after_eviction(self): | ||
| 1737 | + from torch_npu.npu._graph_resource_pool import GraphResourcePool | ||
| 1738 | + | ||
| 1739 | + shape_configs = [ | ||
| 1740 | + {"type": "BATCHSIZE", "gears": [16, 32, 64], "dimensions": 0, "indices": [0, 1]}, | ||
| 1741 | + ] | ||
| 1742 | + options = _make_adaptive_options(shape_configs, _ST_ADAPTIVE_CONFIGS) | ||
| 1743 | + options["triton.cudagraphs"] = True | ||
| 1744 | + options["triton.cudagraph_trees"] = True | ||
| 1745 | + | ||
| 1746 | + compiled_fn = torch.compile( | ||
| 1747 | + model_fn, | ||
| 1748 | + backend="inductor", | ||
| 1749 | + dynamic=False, | ||
| 1750 | + options=options, | ||
| 1751 | + ) | ||
| 1752 | + manager = self._get_manager() | ||
| 1753 | + self._stop_daemon(manager) | ||
| 1754 | + | ||
| 1755 | + for shape in [(8, 32), (24, 32), (48, 32)]: | ||
| 1756 | + for _ in range(2): | ||
| 1757 | + out, A, B = _run_model(compiled_fn, shape) | ||
| 1758 | + self.assertTrue(torch.allclose(out, A + B)) | ||
| 1759 | + | ||
| 1760 | + device_index = torch.npu.current_device() | ||
| 1761 | + pool = GraphResourcePool.get_pool(device_index) | ||
| 1762 | + | ||
| 1763 | + self.assertGreater( | ||
| 1764 | + pool.entry_count, 0, | ||
| 1765 | + f"Pool should have entries after compilation, got {pool.entry_count}", | ||
| 1766 | + ) | ||
| 1767 | + | ||
| 1768 | + manager.worker.run_once(time.time()) | ||
| 1769 | + | ||
| 1770 | + snapshot_after = manager.get_snapshot() | ||
| 1771 | + gears_after = sorted(snapshot_after.active_gears["BATCHSIZE"]) | ||
| 1772 | + | ||
| 1773 | + self.assertEqual( | ||
| 1774 | + gears_after, [8, 24, 32, 48, 64], | ||
| 1775 | + f"Expected [8, 24, 32, 48, 64] after eviction+additions, got {gears_after}", | ||
| 1776 | + ) | ||
| 1777 | + | ||
| 1778 | + out, A, B = _run_model(compiled_fn, (24, 32)) | ||
| 1779 | + self.assertTrue( | ||
| 1780 | + torch.allclose(out, A + B), | ||
| 1781 | + "Computation should be correct after graph cleanup", | ||
| 1782 | + ) | ||
| 1783 | + | ||
| 1784 | + def test_adaptive_gears_daemon_thread_triggers_update(self): | ||
| 1785 | + shape_configs = [ | ||
| 1786 | + {"type": "BATCHSIZE", "gears": [16, 32, 64], "dimensions": 0, "indices": [0, 1]}, | ||
| 1787 | + ] | ||
| 1788 | + options = _make_adaptive_options(shape_configs, _ST_DAEMON_ADAPTIVE_CONFIGS) | ||
| 1789 | + | ||
| 1790 | + compiled_fn = torch.compile( | ||
| 1791 | + model_fn, | ||
| 1792 | + backend="inductor", | ||
| 1793 | + dynamic=False, | ||
| 1794 | + options=options, | ||
| 1795 | + ) | ||
| 1796 | + manager = self._get_manager() | ||
| 1797 | + original_gears = sorted(manager.get_snapshot().active_gears["BATCHSIZE"]) | ||
| 1798 | + | ||
| 1799 | + for _ in range(5): | ||
| 1800 | + out, A, B = _run_model(compiled_fn, (24, 32)) | ||
| 1801 | + self.assertTrue(torch.allclose(out, A + B)) | ||
| 1802 | + | ||
| 1803 | + self.assertTrue( | ||
| 1804 | + manager._worker_thread.is_alive(), | ||
| 1805 | + "Daemon thread should be alive", | ||
| 1806 | + ) | ||
| 1807 | + | ||
| 1808 | + deadline = time.time() + 5.0 | ||
| 1809 | + current_gears = original_gears | ||
| 1810 | + while time.time() < deadline: | ||
| 1811 | + current_gears = sorted(manager.get_snapshot().active_gears["BATCHSIZE"]) | ||
| 1812 | + if current_gears != original_gears: | ||
| 1813 | + break | ||
| 1814 | + time.sleep(0.1) | ||
| 1815 | + | ||
| 1816 | + self.assertNotEqual( | ||
| 1817 | + current_gears, original_gears, | ||
| 1818 | + f"Daemon thread should trigger gear update within 5s. " | ||
| 1819 | + f"Original: {original_gears}, Current: {current_gears}", | ||
| 1820 | + ) | ||
| 1821 | + | ||
| 1822 | + out, A, B = _run_model(compiled_fn, (24, 32)) | ||
| 1823 | + self.assertTrue( | ||
| 1824 | + torch.allclose(out, A + B), | ||
| 1825 | + "Computation should be correct after daemon-triggered update", | ||
| 1826 | + ) | ||
| 1827 | + | ||
| 1828 | + | ||
| 810 | instantiate_parametrized_tests(TestShapeHandling) | 1829 | instantiate_parametrized_tests(TestShapeHandling) |
| 811 | instantiate_parametrized_tests(TestShapeHandlingBranchCoverage) | 1830 | instantiate_parametrized_tests(TestShapeHandlingBranchCoverage) |
| 1831 | +instantiate_parametrized_tests(TestAsyncWorkerAndConcurrency) | ||
| 1832 | +instantiate_parametrized_tests(TestAdaptiveShapeHandling) | ||
| 812 | instantiate_parametrized_tests(TestUnifiedCopy) | 1833 | instantiate_parametrized_tests(TestUnifiedCopy) |
| 813 | instantiate_parametrized_tests(TestDynamicShapeCompile) | 1834 | instantiate_parametrized_tests(TestDynamicShapeCompile) |
| 1835 | +instantiate_parametrized_tests(TestAdaptiveGearsCompileST) | ||
| 814 | 1836 | ||
| 815 | if __name__ == '__main__': | 1837 | if __name__ == '__main__': |
| 816 | run_tests() | 1838 | run_tests() |
| @@ -1,4 +1,5 @@ | |||
| 1 | import os | 1 | import os |
| 2 | +import threading | ||
| 2 | 3 | ||
| 3 | os.environ["ASCEND_LAUNCH_BLOCKING"] = "0" | 4 | os.environ["ASCEND_LAUNCH_BLOCKING"] = "0" |
| 4 | 5 | ||
| @@ -7,6 +8,7 @@ import weakref | |||
| 7 | import pytest | 8 | import pytest |
| 8 | import torch | 9 | import torch |
| 9 | import torch_npu | 10 | import torch_npu |
| 11 | +from torch_npu.npu._graph_resource_pool import GraphResourcePool | ||
| 10 | from torch_npu.npu._graph_tree import ( | 12 | from torch_npu.npu._graph_tree import ( |
| 11 | check_memory_pool, | 13 | check_memory_pool, |
| 12 | clear_cublass_cache, | 14 | clear_cublass_cache, |
| @@ -172,7 +174,8 @@ class TestTreeManagerContainer(TestCase): | |||
| 172 | ): | 174 | ): |
| 173 | manager = self.container.get_tree_manager() | 175 | manager = self.container.get_tree_manager() |
| 174 | self.assertIsNotNone(manager) | 176 | self.assertIsNotNone(manager) |
| 175 | - self.assertIs(manager, self.container.get_tree_manager()) # Same instance | 177 | + # Same instance |
| 178 | + self.assertIs(manager, self.container.get_tree_manager()) | ||
| 176 | 179 | ||
| 177 | 180 | ||
| 178 | class TestStorageWeakRefWrapper(TestCase): | 181 | class TestStorageWeakRefWrapper(TestCase): |
| @@ -1205,6 +1208,321 @@ class TestNPUGraphTreeManager: | |||
| 1205 | ) | 1208 | ) |
| 1206 | assert FunctionID(1) in manager.warned_functions | 1209 | assert FunctionID(1) in manager.warned_functions |
assert需要补充注释 ![]() ![]() | |||
| 1207 | 1210 | ||
| 1211 | + | ||
缺少边缘情况测试(如空输入、异常形状 ![]() ![]() | |||
| 1212 | + | ||
| 1213 | + def test_record_function_pool_registration(self, mock_node, mock_synchronize): | ||
| 1214 | + """Both root and child nodes are registered in GraphResourcePool.""" | ||
| 1215 | + GraphResourcePool.reset_all() | ||
| 1216 | + manager = NPUGraphTreeManager(0) | ||
| 1217 | + manager.ids_to_funcs[FunctionID(1)] = MagicMock() | ||
| 1218 | + manager.ids_to_stack_traces[FunctionID(1)] = "stack_trace" | ||
| 1219 | + manager.npu_graphs_thread_pool = "pool_handle" | ||
| 1220 | + manager.device_index = 0 | ||
| 1221 | + manager.stream = MagicMock() | ||
| 1222 | + mock_node_instance = MagicMock() | ||
| 1223 | + mock_node.return_value = mock_node_instance | ||
| 1224 | + mock_node_instance.run_first_inputs.return_value = [torch.tensor([1.0])] | ||
| 1225 | + | ||
| 1226 | + t1 = torch.tensor([1.0]) | ||
| 1227 | + t2 = torch.tensor([2.0, 3.0]) | ||
| 1228 | + manager.record_function([t1, t2], FunctionID(1)) | ||
| 1229 | + | ||
| 1230 | + # Root node: registered in the pool with an opaque integer key. | ||
| 1231 | + pool = GraphResourcePool.get_pool(0) | ||
| 1232 | + assert pool.entry_count == 1 | ||
| 1233 | + root_keys = pool.consume_recent_keys() | ||
| 1234 | + assert len(root_keys) == 1 | ||
| 1235 | + root_key = root_keys[0] | ||
| 1236 | + assert isinstance(root_key, int) | ||
| 1237 | + assert pool._entries[root_key] is mock_node_instance | ||
| 1238 | + # consume must drain the per-thread pending buffer. | ||
| 1239 | + assert pool.consume_recent_keys() == [] | ||
| 1240 | + | ||
| 1241 | + # Child node: also registered with its own key. | ||
| 1242 | + parent_node = MagicMock() | ||
| 1243 | + manager.current_node = parent_node | ||
| 1244 | + t3 = torch.tensor([4.0, 5.0, 6.0]) | ||
| 1245 | + child_instance = MagicMock() | ||
| 1246 | + mock_node.return_value = child_instance | ||
| 1247 | + child_instance.run_first_inputs.return_value = [torch.tensor([1.0])] | ||
| 1248 | + manager.record_function([t3], FunctionID(1)) | ||
| 1249 | + | ||
| 1250 | + assert pool.entry_count == 2 | ||
| 1251 | + child_keys = pool.consume_recent_keys() | ||
| 1252 | + assert len(child_keys) == 1 | ||
| 1253 | + assert pool._entries[child_keys[0]] is child_instance | ||
| 1254 | + # consume must drain the buffer again. | ||
| 1255 | + assert pool.consume_recent_keys() == [] | ||
| 1256 | + GraphResourcePool.reset_all() | ||
| 1257 | + | ||
| 1258 | + | ||
| 1259 | +class TestGraphResourcePool: | ||
| 1260 | + """Tests for opaque-key registration / removal in GraphResourcePool.""" | ||
| 1261 | + | ||
| 1262 | + def setup_method(self): | ||
| 1263 | + GraphResourcePool.reset_all() | ||
| 1264 | + | ||
| 1265 | + def teardown_method(self): | ||
| 1266 | + GraphResourcePool.reset_all() | ||
| 1267 | + | ||
| 1268 | + # helpers ---------------------------------------------------------- | ||
| 1269 | + | ||
| 1270 | + | ||
| 1271 | + def _register_resource(pool, resource=None): | ||
| 1272 | + """Activate the pool, register *resource* (or a fresh MagicMock), | ||
| 1273 | + and return (key, resource).""" | ||
| 1274 | + pool.activate() | ||
| 1275 | + if resource is None: | ||
| 1276 | + resource = MagicMock() | ||
| 1277 | + key = pool.register(resource) | ||
| 1278 | + assert isinstance(key, int) | ||
| 1279 | + return key, resource | ||
| 1280 | + | ||
| 1281 | + # tests ------------------------------------------------------------ | ||
| 1282 | + | ||
| 1283 | + def test_activate_deactivate(self): | ||
| 1284 | + """Default False, activate → True, deactivate → False.""" | ||
| 1285 | + pool = GraphResourcePool.get_pool(0) | ||
| 1286 | + assert not pool.is_active() | ||
| 1287 | + pool.activate() | ||
| 1288 | + assert pool.is_active() | ||
| 1289 | + pool.deactivate() | ||
| 1290 | + assert not pool.is_active() | ||
| 1291 | + | ||
| 1292 | + def test_activate_refcount(self): | ||
| 1293 | + """Nested activate/deactivate — outer activation survives inner | ||
| 1294 | + deactivate.""" | ||
| 1295 | + pool = GraphResourcePool.get_pool(0) | ||
| 1296 | + pool.activate() # outer | ||
| 1297 | + pool.activate() # inner | ||
| 1298 | + pool.deactivate() # close inner — outer still active | ||
| 1299 | + assert pool.is_active() | ||
| 1300 | + key = pool.register(MagicMock()) | ||
| 1301 | + assert key >= 0 | ||
| 1302 | + assert pool.entry_count == 1 | ||
| 1303 | + pool.deactivate() # close outer — now disabled | ||
| 1304 | + assert not pool.is_active() | ||
| 1305 | + | ||
| 1306 | + | ||
| 1307 | + def test_register_returns_int_key(self, _mock_sync): | ||
| 1308 | + """register() returns an opaque integer key when activated.""" | ||
| 1309 | + pool = GraphResourcePool.get_pool(0) | ||
| 1310 | + key, _ = self._register_resource(pool) | ||
| 1311 | + assert isinstance(key, int) | ||
| 1312 | + assert pool.entry_count == 1 | ||
| 1313 | + | ||
| 1314 | + # -- consume_recent_keys ----------------------------------------------- | ||
| 1315 | + | ||
| 1316 | + def test_consume_recent_keys_returns_registered_keys(self): | ||
| 1317 | + """consume_recent_keys returns all keys registered since last consume.""" | ||
| 1318 | + pool = GraphResourcePool.get_pool(0) | ||
| 1319 | + k1, _ = self._register_resource(pool) | ||
| 1320 | + k2, _ = self._register_resource(pool) | ||
| 1321 | + assert pool.entry_count == 2 | ||
| 1322 | + keys = pool.consume_recent_keys() | ||
| 1323 | + assert keys == [k1, k2] | ||
| 1324 | + | ||
| 1325 | + def test_consume_recent_keys_drains_buffer(self): | ||
| 1326 | + """A second consume_recent_keys returns [] (buffer was drained).""" | ||
| 1327 | + pool = GraphResourcePool.get_pool(0) | ||
| 1328 | + self._register_resource(pool) | ||
| 1329 | + self._register_resource(pool) | ||
| 1330 | + first = pool.consume_recent_keys() | ||
| 1331 | + assert len(first) == 2 | ||
| 1332 | + # Buffer must be empty after consume. | ||
| 1333 | + assert pool.consume_recent_keys() == [] | ||
| 1334 | + | ||
| 1335 | + def test_consume_recent_keys_empty_when_no_registration(self): | ||
| 1336 | + """consume_recent_keys returns [] when nothing was registered.""" | ||
| 1337 | + pool = GraphResourcePool.get_pool(0) | ||
| 1338 | + assert pool.consume_recent_keys() == [] | ||
| 1339 | + | ||
| 1340 | + def test_consume_recent_keys_thread_isolation(self): | ||
| 1341 | + """Each thread sees only its own registrations.""" | ||
| 1342 | + pool = GraphResourcePool.get_pool(0) | ||
| 1343 | + results = {} | ||
| 1344 | + | ||
| 1345 | + def register_and_consume(thread_id): | ||
| 1346 | + r1 = MagicMock() | ||
| 1347 | + r2 = MagicMock() | ||
| 1348 | + k1 = pool.register(r1) | ||
| 1349 | + k2 = pool.register(r2) | ||
| 1350 | + keys = pool.consume_recent_keys() | ||
| 1351 | + results[thread_id] = (k1, k2, keys) | ||
| 1352 | + | ||
| 1353 | + t_a = threading.Thread(target=register_and_consume, args=(0,)) | ||
| 1354 | + t_b = threading.Thread(target=register_and_consume, args=(1,)) | ||
| 1355 | + t_a.start() | ||
| 1356 | + t_b.start() | ||
| 1357 | + t_a.join() | ||
| 1358 | + t_b.join() | ||
| 1359 | + | ||
| 1360 | + # Each thread must receive exactly its own 2 keys. | ||
| 1361 | + k1_a, k2_a, keys_a = results[0] | ||
| 1362 | + k1_b, k2_b, keys_b = results[1] | ||
| 1363 | + assert keys_a == [k1_a, k2_a] | ||
| 1364 | + assert keys_b == [k1_b, k2_b] | ||
| 1365 | + # Keys across threads are globally unique. | ||
| 1366 | + assert len({k1_a, k2_a, k1_b, k2_b}) == 4 | ||
| 1367 | + assert pool.entry_count == 4 | ||
| 1368 | + | ||
| 1369 | + # Consuming from the main thread must return nothing. | ||
| 1370 | + assert pool.consume_recent_keys() == [] | ||
| 1371 | + | ||
| 1372 | + # -- remove_by_keys ---------------------------------------------------- | ||
| 1373 | + | ||
| 1374 | + | ||
| 1375 | + def test_register_and_remove_single(self, _mock_sync): | ||
| 1376 | + """Register one resource and remove it — basic happy path.""" | ||
| 1377 | + pool = GraphResourcePool.get_pool(0) | ||
| 1378 | + key, resource = self._register_resource(pool) | ||
| 1379 | + assert pool.entry_count == 1 | ||
| 1380 | + pool.remove_by_keys([key]) | ||
| 1381 | + resource.release.assert_called_once() | ||
| 1382 | + assert pool.entry_count == 0 | ||
| 1383 | + | ||
| 1384 | + | ||
| 1385 | + def test_remove_unknown_key_is_noop(self, _mock_sync): | ||
| 1386 | + """Removing a key that was never registered must not touch anything.""" | ||
| 1387 | + pool = GraphResourcePool.get_pool(0) | ||
| 1388 | + key, resource = self._register_resource(pool) | ||
| 1389 | + # Use a key that is semantically impossible (negative). | ||
| 1390 | + pool.remove_by_keys([-1]) | ||
| 1391 | + resource.release.assert_not_called() | ||
| 1392 | + assert pool.entry_count == 1 | ||
| 1393 | + | ||
| 1394 | + | ||
| 1395 | + def test_multiple_resources_independent_keys(self, _mock_sync): | ||
| 1396 | + """Each register() returns a distinct key; removing one doesn't | ||
| 1397 | + affect the other.""" | ||
| 1398 | + pool = GraphResourcePool.get_pool(0) | ||
| 1399 | + k1, r1 = self._register_resource(pool) | ||
| 1400 | + k2, r2 = self._register_resource(pool) | ||
| 1401 | + assert k1 != k2 | ||
| 1402 | + assert pool.entry_count == 2 | ||
| 1403 | + pool.remove_by_keys([k1]) | ||
| 1404 | + r1.release.assert_called_once() | ||
| 1405 | + r2.release.assert_not_called() | ||
| 1406 | + assert pool.entry_count == 1 | ||
| 1407 | + | ||
| 1408 | + | ||
| 1409 | + def test_pool_isolation_across_devices(self, _mock_sync): | ||
| 1410 | + """Per-device pools are independent.""" | ||
| 1411 | + pool_0 = GraphResourcePool.get_pool(0) | ||
| 1412 | + pool_1 = GraphResourcePool.get_pool(1) | ||
| 1413 | + assert pool_0 is not pool_1 | ||
assert需要补充注释 ![]() ![]() | |||
| 1414 | + k0, r0 = self._register_resource(pool_0) | ||
| 1415 | + k1, r1 = self._register_resource(pool_1) | ||
| 1416 | + pool_0.remove_by_keys([k0]) | ||
| 1417 | + r0.release.assert_called_once() | ||
| 1418 | + r1.release.assert_not_called() | ||
| 1419 | + assert pool_1.entry_count == 1 | ||
| 1420 | + | ||
| 1421 | + | ||
| 1422 | + def test_remove_empty_keys_is_noop(self, _mock_sync): | ||
| 1423 | + """Empty key list should not crash or release anything.""" | ||
| 1424 | + pool = GraphResourcePool.get_pool(0) | ||
| 1425 | + key, resource = self._register_resource(pool) | ||
| 1426 | + pool.remove_by_keys([]) | ||
| 1427 | + assert pool.entry_count == 1 | ||
| 1428 | + resource.release.assert_not_called() | ||
| 1429 | + | ||
| 1430 | + | ||
| 1431 | + def test_remove_already_removed_key_is_noop(self, _mock_sync): | ||
| 1432 | + """Removing an already-removed key twice must not crash (idempotency).""" | ||
| 1433 | + pool = GraphResourcePool.get_pool(0) | ||
| 1434 | + key, resource = self._register_resource(pool) | ||
| 1435 | + pool.remove_by_keys([key]) | ||
| 1436 | + assert pool.entry_count == 0 | ||
| 1437 | + resource.release.assert_called_once() | ||
| 1438 | + pool.remove_by_keys([key]) # second removal — must not crash | ||
| 1439 | + assert pool.entry_count == 0 | ||
| 1440 | + # release must NOT be called a second time. | ||
| 1441 | + resource.release.assert_called_once() | ||
| 1442 | + | ||
| 1443 | + # -- lifecycle --------------------------------------------------------- | ||
| 1444 | + | ||
| 1445 | + | ||
| 1446 | + def test_register_consume_remove_lifecycle(self, _mock_sync): | ||
| 1447 | + """Full lifecycle: register → consume → remove, matching upper layer flow. | ||
| 1448 | + | ||
| 1449 | + Simulates the new_fn loop in shape_handling.py where each src_fn | ||
| 1450 | + invocation produces N graph captures (N pool registrations) that are | ||
| 1451 | + then consumed, recorded as cleanup_keys on gears, and later removed | ||
| 1452 | + en masse on gear eviction. | ||
| 1453 | + """ | ||
| 1454 | + pool = GraphResourcePool.get_pool(0) | ||
| 1455 | + | ||
| 1456 | + # -- variant 0: 3 sub-graphs captured -- | ||
| 1457 | + r_a, r_b, r_c = MagicMock(), MagicMock(), MagicMock() | ||
| 1458 | + k_a = pool.register(r_a) | ||
| 1459 | + k_b = pool.register(r_b) | ||
| 1460 | + k_c = pool.register(r_c) | ||
| 1461 | + assert pool.entry_count == 3 | ||
| 1462 | + | ||
| 1463 | + # Upper layer consumes keys for this variant. | ||
| 1464 | + keys_v0 = pool.consume_recent_keys() | ||
| 1465 | + assert keys_v0 == [k_a, k_b, k_c] | ||
| 1466 | + assert pool.consume_recent_keys() == [] # drained | ||
| 1467 | + | ||
| 1468 | + # -- variant 1: 2 sub-graphs captured in a different shape -- | ||
| 1469 | + r_d, r_e = MagicMock(), MagicMock() | ||
| 1470 | + k_d = pool.register(r_d) | ||
| 1471 | + k_e = pool.register(r_e) | ||
| 1472 | + assert pool.entry_count == 5 | ||
| 1473 | + | ||
| 1474 | + keys_v1 = pool.consume_recent_keys() | ||
| 1475 | + assert keys_v1 == [k_d, k_e] | ||
| 1476 | + | ||
| 1477 | + # -- gear eviction removes variant 0 -- | ||
| 1478 | + # All 3 keys for this variant are passed together (they may be | ||
| 1479 | + # distributed across several gears; e.g. BATCHSIZE:32 cleanup_keys | ||
| 1480 | + # also contains them). | ||
| 1481 | + pool.remove_by_keys(keys_v0) | ||
| 1482 | + r_a.release.assert_called_once() | ||
| 1483 | + r_b.release.assert_called_once() | ||
| 1484 | + r_c.release.assert_called_once() | ||
| 1485 | + # Variant 1 resources untouched. | ||
| 1486 | + assert pool.entry_count == 2 | ||
| 1487 | + | ||
| 1488 | + # -- gear eviction removes variant 1 later -- | ||
| 1489 | + pool.remove_by_keys(keys_v1) | ||
| 1490 | + assert pool.entry_count == 0 | ||
| 1491 | + | ||
| 1492 | + # Re-removing any key is harmless (gears share keys → idempotent). | ||
| 1493 | + pool.remove_by_keys(keys_v0) # no crash | ||
| 1494 | + pool.remove_by_keys(keys_v1) # no crash | ||
| 1495 | + assert pool.entry_count == 0 | ||
| 1496 | + | ||
| 1497 | + | ||
| 1498 | + def test_bulk_remove_mixed_keys(self, _mock_sync): | ||
| 1499 | + """Bulk remove handles a mix of existing and already-removed keys.""" | ||
| 1500 | + pool = GraphResourcePool.get_pool(0) | ||
| 1501 | + k1, r1 = self._register_resource(pool) | ||
| 1502 | + k2, r2 = self._register_resource(pool) | ||
| 1503 | + | ||
| 1504 | + # Remove k1 first. | ||
| 1505 | + pool.remove_by_keys([k1]) | ||
| 1506 | + assert pool.entry_count == 1 | ||
| 1507 | + | ||
| 1508 | + # Bulk remove: k1 (already gone) + k2 (still present). | ||
| 1509 | + pool.remove_by_keys([k1, k2]) | ||
| 1510 | + r1.release.assert_called_once() # only once, from first remove | ||
| 1511 | + r2.release.assert_called_once() | ||
| 1512 | + assert pool.entry_count == 0 | ||
| 1513 | + | ||
| 1514 | + | ||
| 1515 | + def test_remove_calls_reset_when_no_release(self, _mock_sync): | ||
| 1516 | + """Simple-mode graph (torch.npu.NPUGraph) uses reset(), not release().""" | ||
| 1517 | + from unittest.mock import Mock | ||
| 1518 | + | ||
| 1519 | + pool = GraphResourcePool.get_pool(0) | ||
| 1520 | + pool.activate() | ||
| 1521 | + resource = Mock(spec_set=['reset']) | ||
| 1522 | + key = pool.register(resource) | ||
| 1523 | + pool.remove_by_keys([key]) | ||
| 1524 | + resource.reset.assert_called_once() | ||
| 1525 | + | ||
增加边界条件测试用例 ![]() ![]() | |||
| 1208 | 1526 | ||
| 1209 | if __name__ == "__main__": | 1527 | if __name__ == "__main__": |
| 1210 | run_tests() | 1528 | run_tests() |
| @@ -0,0 +1,895 @@ | |||
| 1 | +from __future__ import annotations | ||
| 2 | + | ||
| 3 | +import copy | ||
| 4 | +import logging | ||
| 5 | +import math | ||
| 6 | +import statistics | ||
| 7 | +import threading | ||
| 8 | +import time | ||
| 9 | +from dataclasses import dataclass, field | ||
| 10 | +from typing import Any, Callable, Dict, List, Optional, Set, Tuple | ||
| 11 | + | ||
| 12 | +import torch | ||
| 13 | +from torch_npu.npu._graph_resource_pool import GraphResourcePool | ||
| 14 | + | ||
| 15 | +logger = logging.getLogger(__name__) | ||
| 16 | + | ||
| 17 | + | ||
| 18 | +# --------------------------------------------------------------------------- | ||
| 19 | +# Models | ||
| 20 | +# --------------------------------------------------------------------------- | ||
| 21 | + | ||
| 22 | + | ||
| 23 | + | ||
| 24 | +class GearEventSample: | ||
| 25 | + """Single request event sample produced by shape handling, used as input for | ||
| 26 | + time-decayed window statistics (Section 5). | ||
| 27 | + | ||
| 28 | + Fields: | ||
| 29 | + event_ts: Event timestamp in seconds, used for time-decay weight w_t(e) = exp(-λ · Δt). | ||
| 30 | + raw_value: Original dimension value of the request, used for raw length distribution | ||
| 31 | + to generate addition candidates via median. | ||
| 32 | + pad_ratio: Padding ratio of this request, used for gear weighted average pad cost. | ||
| 33 | + split_ratio: Split ratio of this request, used for gear weighted average split cost. | ||
| 34 | + """ | ||
| 35 | + | ||
| 36 | + event_ts: float | ||
| 37 | + raw_value: int | ||
| 38 | + pad_ratio: float | ||
| 39 | + split_ratio: float | ||
| 40 | + | ||
| 41 | + | ||
| 42 | + | ||
| 43 | +class GearRuntimeState: | ||
| 44 | + """Per-gear runtime state storing decision data for eviction evaluation ("whether to evict"), | ||
| 45 | + not deletion safety ("whether safe to delete") which is handled by tree manager. | ||
| 46 | + | ||
| 47 | + Fields: | ||
| 48 | + gear_id: Unique gear identifier in the format "{shape_type}:{gear_value}". | ||
| 49 | + shape_type: Dimension type this gear belongs to (e.g. "batch_size", "seq_len"). | ||
| 50 | + gear_value: The gear threshold value. | ||
| 51 | + samples: Windowed event sample list, supporting hit_rate, avg_pad, avg_split computation. | ||
| 52 | + created_ts: Gear creation timestamp, used for recent-use protection window. | ||
| 53 | + last_hit_ts: Timestamp of the most recent hit, used for recent-use protection window. | ||
| 54 | + """ | ||
| 55 | + | ||
| 56 | + gear_id: str | ||
| 57 | + shape_type: str | ||
| 58 | + gear_value: int | ||
| 59 | + samples: List[GearEventSample] = field(default_factory=list) | ||
| 60 | + cleanup_keys: Set[int] = field(default_factory=set) | ||
| 61 | + created_ts: float = 0.0 | ||
| 62 | + last_hit_ts: float = 0.0 | ||
| 63 | + | ||
| 64 | + | ||
| 65 | + | ||
| 66 | +class GearSnapshot: | ||
| 67 | + """Snapshot of the current gear set, enabling lock-free reads from the request thread | ||
| 68 | + via clone-on-read and atomic reference swaps on publish. | ||
| 69 | + | ||
| 70 | + Fields: | ||
| 71 | + active_gears: Current active gears, keyed by shape_type, values are gear value lists. | ||
| 72 | + handler_configs: Per-dimension shape handler configs, used to rebuild config on update commit. | ||
| 73 | + shape_handling: Associated NPUShapeHandling instance for accessing graph manager and runtime config. | ||
| 74 | + version: Monotonically increasing version number, assigned at snapshot creation time. | ||
| 75 | + Useful for correlating logs across request and update threads. | ||
| 76 | + created_at: Unix timestamp (``time.time()``) when the snapshot was built. | ||
| 77 | + """ | ||
| 78 | + | ||
| 79 | + active_gears: Dict[str, List[int]] | ||
| 80 | + handler_configs: List[Dict[str, Any]] | ||
| 81 | + shape_handling: Any | ||
| 82 | + version: int = 0 | ||
| 83 | + created_at: float = 0.0 | ||
| 84 | + | ||
| 85 | + def clone(self) -> "GearSnapshot": | ||
| 86 | + """Deep-copy the snapshot. active_gears and handler_configs are independent copies; | ||
| 87 | + shape_handling shares the reference. version and created_at are preserved.""" | ||
| 88 | + return GearSnapshot( | ||
| 89 | + active_gears={shape_type: list(values) for shape_type, values in self.active_gears.items()}, | ||
| 90 | + handler_configs=copy.deepcopy(self.handler_configs), | ||
| 91 | + shape_handling=self.shape_handling, | ||
| 92 | + version=self.version, | ||
| 93 | + created_at=self.created_at, | ||
| 94 | + ) | ||
| 95 | + | ||
| 96 | + | ||
| 97 | +class ScoreBreakdown: | ||
| 98 | + """Scoring result for a single gear, used for eviction ranking, replacement loss recording, | ||
| 99 | + and observability logging (Section 12.2). | ||
| 100 | + | ||
| 101 | + Fields: | ||
| 102 | + gear_id: Unique gear identifier. | ||
| 103 | + shape_type: Dimension type this gear belongs to. | ||
| 104 | + gear_value: The gear threshold value. | ||
| 105 | + hit_rate: Hit rate score component. | ||
| 106 | + avg_pad_ratio: Weighted average padding ratio component. | ||
| 107 | + avg_split_ratio: Weighted average split ratio component. | ||
| 108 | + score: Composite score = w_h * hit_rate - w_p * avg_pad - w_sp * avg_split. | ||
| 109 | + replace_loss: Replacement loss. Weighted average loss incurred when this gear's samples | ||
| 110 | + are remapped to the nearest remaining gear after deletion. | ||
| 111 | + replacement_gear_id: The best alternative gear_id identified during replacement loss computation. | ||
| 112 | + """ | ||
| 113 | + | ||
| 114 | + gear_id: str | ||
| 115 | + shape_type: str | ||
| 116 | + gear_value: int | ||
| 117 | + hit_rate: float | ||
| 118 | + avg_pad_ratio: float | ||
| 119 | + avg_split_ratio: float | ||
| 120 | + score: float | ||
| 121 | + replace_loss: float = 0.0 | ||
| 122 | + replacement_gear_id: Optional[str] = None | ||
| 123 | + | ||
| 124 | + | ||
| 125 | +# --------------------------------------------------------------------------- | ||
| 126 | +# Snapshot | ||
| 127 | +# --------------------------------------------------------------------------- | ||
| 128 | + | ||
| 129 | + | ||
| 130 | +class GearSnapshotStore: | ||
| 131 | + """Thread-safe store for the current gear snapshot. | ||
| 132 | + | ||
| 133 | + Reads are lock-free: a snapshot reference is read atomically (GIL) and | ||
| 134 | + cloned on-read, so the request thread never blocks on writes. Writes | ||
| 135 | + are serialized by a lock to prevent concurrent publishes from interleaving. | ||
| 136 | + """ | ||
| 137 | + | ||
| 138 | + def __init__(self, initial_state: GearSnapshot) -> None: | ||
| 139 | + self._lock = threading.Lock() | ||
| 140 | + self._current_snapshot = initial_state.clone() | ||
| 141 | + | ||
| 142 | + def get_snapshot(self) -> GearSnapshot: | ||
| 143 | + return self._current_snapshot.clone() | ||
| 144 | + | ||
| 145 | + def publish_snapshot(self, snapshot: GearSnapshot) -> GearSnapshot: | ||
| 146 | + with self._lock: | ||
| 147 | + self._current_snapshot = snapshot.clone() | ||
| 148 | + return self._current_snapshot | ||
| 149 | + | ||
| 150 | + | ||
| 151 | +# --------------------------------------------------------------------------- | ||
| 152 | +# Scorer | ||
| 153 | +# --------------------------------------------------------------------------- | ||
| 154 | + | ||
| 155 | + | ||
| 156 | +class GearScorer: | ||
| 157 | + """Scores gears based on time-decayed hit rate, padding cost, and split cost. | ||
| 158 | + | ||
| 159 | + Produces a ``ScoreBreakdown`` per gear used for eviction ranking, and computes | ||
| 160 | + replacement loss to decide whether a specific gear is safe to remove. | ||
| 161 | + """ | ||
| 162 | + | ||
| 163 | + def __init__(self, adaptive_configs: Dict[str, float]) -> None: | ||
| 164 | + self.weight_hit = adaptive_configs.get("weight_hit", 0.60) | ||
| 165 | + self.weight_pad = adaptive_configs.get("weight_pad", 0.20) | ||
| 166 | + self.weight_split = adaptive_configs.get("weight_split", 0.20) | ||
| 167 | + | ||
| 168 | + def build_score_breakdown( | ||
| 169 | + self, | ||
| 170 | + snapshot: GearSnapshot, | ||
| 171 | + stats_snapshot: Dict[str, Dict], | ||
| 172 | + ) -> Dict[str, ScoreBreakdown]: | ||
| 173 | + totals_by_type: Dict[str, float] = {} | ||
| 174 | + for stat in stats_snapshot.values(): | ||
| 175 | + totals_by_type.setdefault(stat["shape_type"], 0.0) | ||
| 176 | + totals_by_type[stat["shape_type"]] += stat["weighted_hits"] | ||
| 177 | + | ||
| 178 | + breakdowns: Dict[str, ScoreBreakdown] = {} | ||
| 179 | + for shape_type, gear_values in snapshot.active_gears.items(): | ||
| 180 | + total_hits = max(totals_by_type.get(shape_type, 0.0), 1e-12) | ||
| 181 | + for gear_value in gear_values: | ||
| 182 | + gear_id = f"{shape_type}:{gear_value}" | ||
| 183 | + stat = stats_snapshot.get(gear_id) | ||
| 184 | + if stat is None: | ||
| 185 | + hit_rate = 0.0 | ||
| 186 | + avg_pad_ratio = 0.0 | ||
| 187 | + avg_split_ratio = 0.0 | ||
| 188 | + else: | ||
| 189 | + hit_rate = stat["weighted_hits"] / total_hits | ||
| 190 | + avg_pad_ratio = stat["avg_pad_ratio"] | ||
| 191 | + avg_split_ratio = stat["avg_split_ratio"] | ||
| 192 | + score = ( | ||
| 193 | + self.weight_hit * hit_rate | ||
| 194 | + - self.weight_pad * avg_pad_ratio | ||
| 195 | + - self.weight_split * avg_split_ratio | ||
| 196 | + ) | ||
| 197 | + breakdowns[gear_id] = ScoreBreakdown( | ||
| 198 | + gear_id=gear_id, | ||
| 199 | + shape_type=shape_type, | ||
| 200 | + gear_value=gear_value, | ||
| 201 | + hit_rate=hit_rate, | ||
| 202 | + avg_pad_ratio=avg_pad_ratio, | ||
| 203 | + avg_split_ratio=avg_split_ratio, | ||
| 204 | + score=score, | ||
| 205 | + ) | ||
| 206 | + return breakdowns | ||
| 207 | + | ||
| 208 | + def compute_replace_loss( | ||
| 209 | + self, | ||
| 210 | + snapshot: GearSnapshot, | ||
| 211 | + stats_snapshot: Dict[str, Dict], | ||
| 212 | + gear_id: str, | ||
| 213 | + ) -> Tuple[float, Optional[str]]: | ||
| 214 | + stat = stats_snapshot.get(gear_id) | ||
| 215 | + if stat is None: | ||
| 216 | + return 0.0, None | ||
| 217 | + | ||
| 218 | + shape_type = stat["shape_type"] | ||
| 219 | + gear_value = stat["gear_value"] | ||
| 220 | + alternatives = [ | ||
| 221 | + candidate | ||
| 222 | + for candidate in snapshot.active_gears.get(shape_type, []) | ||
| 223 | + if candidate != gear_value | ||
| 224 | + ] | ||
| 225 | + if not alternatives: | ||
| 226 | + return float("inf"), None | ||
| 227 | + | ||
| 228 | + raw_samples = stat.get("raw_samples", []) | ||
| 229 | + if not raw_samples: | ||
| 230 | + replacement = min(alternatives, key=lambda candidate: abs(candidate - gear_value)) | ||
| 231 | + return 0.0, f"{shape_type}:{replacement}" | ||
| 232 | + | ||
| 233 | + best_loss = float("inf") | ||
| 234 | + best_replacement = None | ||
| 235 | + for candidate in alternatives: | ||
| 236 | + total_loss = 0.0 | ||
| 237 | + for raw_value in raw_samples: | ||
| 238 | + if candidate >= raw_value: | ||
| 239 | + total_loss += (candidate - raw_value) / max(candidate, 1) | ||
| 240 | + else: | ||
| 241 | + total_loss += (raw_value - candidate) / max(raw_value, 1) | ||
| 242 | + avg_loss = total_loss / max(len(raw_samples), 1) | ||
| 243 | + if avg_loss < best_loss: | ||
| 244 | + best_loss = avg_loss | ||
| 245 | + best_replacement = candidate | ||
| 246 | + | ||
| 247 | + if best_replacement is None: | ||
| 248 | + return float("inf"), None | ||
| 249 | + return best_loss, f"{shape_type}:{best_replacement}" | ||
| 250 | + | ||
| 251 | + | ||
| 252 | +# --------------------------------------------------------------------------- | ||
| 253 | +# Runtime | ||
| 254 | +# --------------------------------------------------------------------------- | ||
| 255 | + | ||
| 256 | + | ||
| 257 | +class AdaptiveGearRuntime: | ||
| 258 | + """Central coordinator for the adaptive gear update mechanism. | ||
| 259 | + | ||
| 260 | + Manages the main request-path logic (event recording, snapshot reads, update | ||
| 261 | + triggering) and owns the worker thread pool, scorer, and snapshot store. | ||
| 262 | + Graph cleanup after gear eviction is delegated to | ||
| 263 | + GraphResourcePool.remove_by_keys. | ||
| 264 | + """ | ||
| 265 | + | ||
| 266 | + DEFAULT_CONFIG = { | ||
| 267 | + "window_seconds": 300.0, | ||
| 268 | + "update_interval_seconds": 60.0, | ||
| 269 | + "pad_add_threshold": 0.35, | ||
| 270 | + "split_add_threshold": 0.20, | ||
| 271 | + "min_samples_per_gear": 5, | ||
| 272 | + "add_min_samples": 5, | ||
| 273 | + "min_gear_count_per_type": 1, | ||
| 274 | + "max_gears_per_type": 64, | ||
| 275 | + "recent_use_protect_seconds": 300.0, | ||
| 276 | + "replace_loss_threshold": 0.60, | ||
| 277 | + "weight_hit": 0.60, | ||
| 278 | + "weight_pad": 0.20, | ||
| 279 | + "weight_split": 0.20, | ||
| 280 | + "device_memory_usage_threshold_ratio": 0.90, | ||
| 281 | + } | ||
| 282 | + | ||
| 283 | + def __init__( | ||
| 284 | + self, | ||
| 285 | + shape_handling_configs: List[Dict[str, Any]], | ||
| 286 | + adaptive_configs: Dict[str, Any], | ||
| 287 | + shape_handling_builder: Callable[[List[Dict[str, Any]]], Any], | ||
| 288 | + snapshot_store: Optional[GearSnapshotStore] = None, | ||
| 289 | + scorer: Optional[GearScorer] = None, | ||
| 290 | + ) -> None: | ||
| 291 | + self.device_index = torch.npu.current_device() | ||
| 292 | + self.shape_handling_builder = shape_handling_builder | ||
| 293 | + self.config = dict(self.DEFAULT_CONFIG) | ||
| 294 | + if adaptive_configs: | ||
| 295 | + self.config.update(adaptive_configs) | ||
这里直接使用用户输入的adaptive_configs进行update, 缺少合法性校验 ![]() ![]() | |||
| 296 | + | ||
| 297 | + self.scorer = scorer or GearScorer(self.config) | ||
| 298 | + self._sample_lock = threading.Lock() | ||
| 299 | + self._commit_lock = threading.Lock() | ||
| 300 | + self._snapshot_version = 0 | ||
| 301 | + | ||
| 302 | + normalized_configs = self._normalize_configs(shape_handling_configs) | ||
| 303 | + self.config_by_type = {config["type"]: config for config in normalized_configs} | ||
| 304 | + self._cached_ordered_configs = [ | ||
| 305 | + self.config_by_type[shape_type] for shape_type in sorted(self.config_by_type.keys()) | ||
| 306 | + ] | ||
| 307 | + initial_snapshot = self._build_snapshot(normalized_configs) | ||
| 308 | + self.snapshot_store = snapshot_store or GearSnapshotStore(initial_snapshot) | ||
| 309 | + self._states: Dict[str, GearRuntimeState] = {} | ||
| 310 | + self._ensure_runtime_states(initial_snapshot) | ||
| 311 | + | ||
| 312 | + self.worker = GearUpdateWorker(self) | ||
| 313 | + | ||
| 314 | + self._shutdown_event = threading.Event() | ||
| 315 | + self._worker_thread = threading.Thread( | ||
| 316 | + target=self._update_loop, | ||
| 317 | + daemon=True, | ||
| 318 | + name="adaptive-gears-loop", | ||
| 319 | + ) | ||
| 320 | + self._worker_thread.start() | ||
| 321 | + | ||
| 322 | + def shutdown(self) -> None: | ||
| 323 | + self._shutdown_event.set() | ||
| 324 | + if self._worker_thread is not None and self._worker_thread.is_alive(): | ||
| 325 | + self._worker_thread.join(timeout=5.0) | ||
| 326 | + | ||
| 327 | + def get_snapshot(self) -> GearSnapshot: | ||
| 328 | + return self.snapshot_store.get_snapshot() | ||
| 329 | + | ||
| 330 | + def record_event( | ||
| 331 | + self, | ||
| 332 | + raw_gear_values: List[List[Optional[int]]], | ||
| 333 | + mapped_gear_values: List[List[Optional[int]]], | ||
| 334 | + pad_ratios: List[List[float]], | ||
| 335 | + split_ratios: List[List[float]], | ||
| 336 | + event_ts: float, | ||
| 337 | + cleanup_key: Optional[int] = None, | ||
| 338 | + ) -> None: | ||
| 339 | + if not raw_gear_values or not mapped_gear_values: | ||
| 340 | + return | ||
| 341 | + with self._sample_lock: | ||
| 342 | + for index, config in enumerate(self._ordered_configs()): | ||
| 343 | + raw_per_tensor = self._value_at(raw_gear_values, index, default=[]) | ||
| 344 | + mapped_per_tensor = self._value_at(mapped_gear_values, index, default=[]) | ||
| 345 | + cfg_pad = self._value_at(pad_ratios, index, default=[]) | ||
| 346 | + cfg_split = self._value_at(split_ratios, index, default=[]) | ||
| 347 | + for tensor_idx, (raw_value, mapped_value) in enumerate( | ||
| 348 | + zip(raw_per_tensor, mapped_per_tensor) | ||
| 349 | + ): | ||
| 350 | + if raw_value is None or mapped_value is None: | ||
| 351 | + continue | ||
| 352 | + gear_id = f"{config['type']}:{mapped_value}" | ||
| 353 | + state = self._states.get(gear_id) | ||
| 354 | + if state is None: | ||
| 355 | + state = GearRuntimeState( | ||
| 356 | + gear_id=gear_id, | ||
| 357 | + shape_type=config["type"], | ||
| 358 | + gear_value=mapped_value, | ||
| 359 | + ) | ||
| 360 | + self._states[gear_id] = state | ||
| 361 | + sample = GearEventSample( | ||
| 362 | + event_ts=event_ts, | ||
| 363 | + raw_value=raw_value, | ||
| 364 | + pad_ratio=float(self._value_at(cfg_pad, tensor_idx, default=0.0) or 0.0), | ||
| 365 | + split_ratio=float(self._value_at(cfg_split, tensor_idx, default=0.0) or 0.0), | ||
| 366 | + ) | ||
| 367 | + state.samples.append(sample) | ||
| 368 | + if cleanup_key is not None: | ||
| 369 | + state.cleanup_keys.add(cleanup_key) | ||
| 370 | + state.last_hit_ts = event_ts | ||
| 371 | + | ||
| 372 | + def build_stats_snapshot(self, now_ts: float) -> Dict[str, Dict[str, Any]]: | ||
| 373 | + decay_lambda = math.log(2.0) / max(self.config["window_seconds"] / 2.0, 1e-6) | ||
| 374 | + window_seconds = self.config["window_seconds"] | ||
| 375 | + stats_snapshot: Dict[str, Dict[str, Any]] = {} | ||
| 376 | + with self._sample_lock: | ||
| 377 | + for gear_id, state in self._states.items(): | ||
| 378 | + weighted_hits = 0.0 | ||
| 379 | + weighted_pad_sum = 0.0 | ||
| 380 | + weighted_split_sum = 0.0 | ||
| 381 | + raw_samples = [] | ||
| 382 | + kept_samples = [] | ||
| 383 | + pad_sample_count = 0 | ||
| 384 | + split_sample_count = 0 | ||
| 385 | + for sample in state.samples: | ||
| 386 | + age = now_ts - sample.event_ts | ||
| 387 | + if age > window_seconds: | ||
| 388 | + continue | ||
| 389 | + weight = math.exp(-decay_lambda * max(age, 0.0)) | ||
| 390 | + weighted_hits += weight | ||
| 391 | + weighted_pad_sum += sample.pad_ratio * weight | ||
| 392 | + weighted_split_sum += sample.split_ratio * weight | ||
| 393 | + raw_samples.append(sample.raw_value) | ||
| 394 | + if sample.pad_ratio > 0.0: | ||
| 395 | + pad_sample_count += 1 | ||
| 396 | + if sample.split_ratio > 0.0: | ||
| 397 | + split_sample_count += 1 | ||
| 398 | + kept_samples.append(sample) | ||
| 399 | + state.samples = kept_samples | ||
| 400 | + avg_pad_ratio = weighted_pad_sum / max(weighted_hits, 1e-12) | ||
| 401 | + avg_split_ratio = weighted_split_sum / max(weighted_hits, 1e-12) | ||
| 402 | + stats_snapshot[gear_id] = { | ||
| 403 | + "gear_id": gear_id, | ||
| 404 | + "shape_type": state.shape_type, | ||
| 405 | + "gear_value": state.gear_value, | ||
| 406 | + "weighted_hits": weighted_hits, | ||
| 407 | + "avg_pad_ratio": avg_pad_ratio, | ||
| 408 | + "avg_split_ratio": avg_split_ratio, | ||
| 409 | + "sample_count": len(raw_samples), | ||
| 410 | + "pad_sample_count": pad_sample_count, | ||
| 411 | + "split_sample_count": split_sample_count, | ||
| 412 | + "raw_samples": raw_samples, | ||
| 413 | + "created_ts": state.created_ts, | ||
| 414 | + "last_hit_ts": state.last_hit_ts, | ||
| 415 | + } | ||
| 416 | + current_snapshot = self.get_snapshot() | ||
| 417 | + for shape_type, values in current_snapshot.active_gears.items(): | ||
| 418 | + for gear_value in values: | ||
| 419 | + gear_id = f"{shape_type}:{gear_value}" | ||
| 420 | + stats_snapshot.setdefault( | ||
| 421 | + gear_id, | ||
| 422 | + { | ||
| 423 | + "gear_id": gear_id, | ||
| 424 | + "shape_type": shape_type, | ||
| 425 | + "gear_value": gear_value, | ||
| 426 | + "weighted_hits": 0.0, | ||
| 427 | + "avg_pad_ratio": 0.0, | ||
| 428 | + "avg_split_ratio": 0.0, | ||
| 429 | + "sample_count": 0, | ||
| 430 | + "pad_sample_count": 0, | ||
| 431 | + "split_sample_count": 0, | ||
| 432 | + "raw_samples": [], | ||
| 433 | + "created_ts": 0.0, | ||
| 434 | + "last_hit_ts": 0.0, | ||
| 435 | + }, | ||
| 436 | + ) | ||
| 437 | + return stats_snapshot | ||
| 438 | + | ||
| 439 | + def _update_loop(self) -> None: | ||
| 440 | + check_interval = self.config["update_interval_seconds"] | ||
| 441 | + while not self._shutdown_event.wait(timeout=check_interval): | ||
| 442 | + try: | ||
| 443 | + self.worker.run_once(time.time()) | ||
| 444 | + except Exception: | ||
| 445 | + logger.warning("Adaptive gear update failed", exc_info=True) | ||
| 446 | + | ||
| 447 | + def protect_gear_from_eviction(self, gear_id: str, now_ts: float) -> None: | ||
| 448 | + with self._sample_lock: | ||
| 449 | + state = self._states.get(gear_id) | ||
| 450 | + if state is not None: | ||
| 451 | + state.last_hit_ts = max(state.last_hit_ts, now_ts) | ||
| 452 | + | ||
| 453 | + def commit_update( | ||
| 454 | + self, | ||
| 455 | + configs: List[Dict[str, Any]], | ||
| 456 | + removed_gears: List[str], | ||
| 457 | + now_ts: float = 0.0, | ||
| 458 | + ) -> Tuple[Optional[GearSnapshot], List[int]]: | ||
| 459 | + next_snapshot = self._build_snapshot(configs) | ||
| 460 | + published_snapshot = self.snapshot_store.publish_snapshot(next_snapshot) | ||
| 461 | + self._ensure_runtime_states(published_snapshot, created_ts=now_ts) | ||
| 462 | + removed_keys = self._collect_cleanup_keys(removed_gears) | ||
| 463 | + with self._sample_lock: | ||
| 464 | + for gear_id in removed_gears: | ||
| 465 | + self._states.pop(gear_id, None) | ||
| 466 | + return published_snapshot, removed_keys | ||
| 467 | + | ||
| 468 | + def build_resource_budget(self) -> Dict[str, Any]: | ||
| 469 | + device_memory_threshold = self._normalize_ratio(self.config.get("device_memory_usage_threshold_ratio")) | ||
| 470 | + device_memory_usage_ratio = self._get_device_memory_usage_ratio() | ||
| 471 | + return { | ||
| 472 | + "device_memory_usage_ratio": device_memory_usage_ratio, | ||
| 473 | + "device_memory_usage_threshold_ratio": device_memory_threshold, | ||
| 474 | + "device_memory_usage_high": ( | ||
| 475 | + device_memory_threshold is not None | ||
| 476 | + and device_memory_usage_ratio is not None | ||
| 477 | + and device_memory_usage_ratio >= device_memory_threshold | ||
| 478 | + ), | ||
| 479 | + } | ||
| 480 | + | ||
| 481 | + def _ordered_configs(self) -> List[Dict[str, Any]]: | ||
| 482 | + return self._cached_ordered_configs | ||
| 483 | + | ||
| 484 | + def _normalize_configs(self, configs: List[Dict[str, Any]]) -> List[Dict[str, Any]]: | ||
| 485 | + """Normalize user-supplied shape-handling configs into a canonical form. | ||
| 486 | + | ||
| 487 | + Two paths: | ||
| 488 | + 1. *Explicit gears* — the user provides a ``gears`` list. Values are | ||
| 489 | + deduplicated, cast to int, and sorted. | ||
| 490 | + 2. *TIMES policy* — ``gears`` is empty; sizes are auto-generated from | ||
| 491 | + ``min_size``, ``max_size`` and the TIMES (power-of-two) strategy via | ||
| 492 | + ``_expand_policy_gears``. The expansion algorithm is kept consistent | ||
| 493 | + with ``NPUShapeHandling::GenerateGears`` in the C++ layer so that the | ||
| 494 | + initial gear set derived here matches what the C++ engine would | ||
| 495 | + produce internally. | ||
| 496 | + | ||
| 497 | + The function modifies the config dicts in place and returns the same | ||
| 498 | + list (or ``[]`` for empty / None input). | ||
| 499 | + """ | ||
| 500 | + if not configs: | ||
| 501 | + return [] | ||
| 502 | + for config in configs: | ||
| 503 | + gears = config.get("gears") or self._expand_policy_gears(config) | ||
| 504 | + config["gears"] = sorted(set(int(gear) for gear in gears)) | ||
| 505 | + return configs | ||
| 506 | + | ||
| 507 | + def _expand_policy_gears(self, config: Dict[str, Any]) -> List[int]: | ||
建议对这个算法补充注释说明 ![]() ![]() | |||
| 508 | + """Expand ``min_size`` .. ``max_size`` using the TIMES (power-of-two) strategy. | ||
| 509 | + | ||
| 510 | + The factor 2 is chosen because it keeps the gear count logarithmic | ||
| 511 | + relative to the size range — each gear is at most 2× the previous one, | ||
| 512 | + bounding the worst-case padding waste to ≤ 50 % while avoiding an | ||
| 513 | + explosion of graph variants. For example, range [1, 64] produces only | ||
| 514 | + 7 gears: [1, 2, 4, 8, 16, 32, 64]. | ||
| 515 | + | ||
| 516 | + This implementation mirrors ``NPUShapeHandling::GenerateGears`` in C++: | ||
| 517 | + it anchors at ``min_size``, then jumps to the next power of two and | ||
| 518 | + doubles from there, finally appending ``max_size`` if it was not already | ||
| 519 | + covered. | ||
| 520 | + """ | ||
| 521 | + import math | ||
| 522 | + | ||
| 523 | + min_size = int(config.get("min_size", 1)) | ||
| 524 | + max_size = int(config.get("max_size", min_size)) | ||
| 525 | + if max_size <= min_size: | ||
| 526 | + return [min_size] | ||
| 527 | + gears = [min_size] | ||
| 528 | + exp = math.ceil(math.log2(min_size)) if min_size > 0 else 0 | ||
| 529 | + gear = 1 << exp | ||
| 530 | + if gear == min_size: | ||
| 531 | + gear <<= 1 | ||
| 532 | + while gear > 0 and gear <= max_size: | ||
| 533 | + gears.append(gear) | ||
| 534 | + if gear > max_size // 2: | ||
| 535 | + break | ||
| 536 | + gear <<= 1 | ||
| 537 | + if gears[-1] != max_size: | ||
| 538 | + gears.append(max_size) | ||
| 539 | + return gears | ||
| 540 | + | ||
| 541 | + def _build_snapshot(self, configs: List[Dict[str, Any]]) -> GearSnapshot: | ||
snapshot是否可以增加版本号之类的信息,方便出现问题定位是哪个版本出的问题 ![]() ![]() | |||
| 542 | + self._snapshot_version += 1 | ||
| 543 | + active_gears = {config["type"]: list(config.get("gears", [])) for config in configs} | ||
| 544 | + shape_handling = self.shape_handling_builder(copy.deepcopy(configs)) | ||
| 545 | + return GearSnapshot( | ||
| 546 | + active_gears=active_gears, | ||
| 547 | + handler_configs=copy.deepcopy(configs), | ||
| 548 | + shape_handling=shape_handling, | ||
| 549 | + version=self._snapshot_version, | ||
| 550 | + created_at=time.time(), | ||
| 551 | + ) | ||
| 552 | + | ||
| 553 | + def _ensure_runtime_states(self, snapshot: GearSnapshot, created_ts: float = 0.0) -> None: | ||
| 554 | + with self._sample_lock: | ||
| 555 | + for shape_type, gear_values in snapshot.active_gears.items(): | ||
| 556 | + for gear_value in gear_values: | ||
| 557 | + gear_id = f"{shape_type}:{gear_value}" | ||
| 558 | + if gear_id not in self._states: | ||
| 559 | + self._states[gear_id] = GearRuntimeState( | ||
| 560 | + gear_id=gear_id, | ||
| 561 | + shape_type=shape_type, | ||
| 562 | + gear_value=gear_value, | ||
| 563 | + created_ts=created_ts, | ||
| 564 | + ) | ||
| 565 | + | ||
| 566 | + def _collect_cleanup_keys(self, gear_ids: List[str]) -> List[int]: | ||
| 567 | + keys: set[int] = set() | ||
| 568 | + with self._sample_lock: | ||
| 569 | + for gear_id in gear_ids: | ||
| 570 | + state = self._states.get(gear_id) | ||
| 571 | + if state is None: | ||
| 572 | + continue | ||
| 573 | + keys.update(state.cleanup_keys) | ||
| 574 | + state.cleanup_keys.clear() | ||
| 575 | + return list(keys) | ||
| 576 | + | ||
| 577 | + def _normalize_ratio(self, ratio_value): | ||
| 578 | + if ratio_value is None: | ||
| 579 | + return None | ||
| 580 | + try: | ||
| 581 | + normalized = float(ratio_value) | ||
| 582 | + except (TypeError, ValueError): | ||
| 583 | + return None | ||
| 584 | + if normalized <= 0.0 or normalized > 1.0: | ||
| 585 | + return None | ||
| 586 | + return normalized | ||
| 587 | + | ||
| 588 | + def _get_device_memory_usage_ratio(self): | ||
| 589 | + try: | ||
| 590 | + free_bytes, total_bytes = torch.npu.mem_get_info() | ||
| 591 | + except Exception: | ||
| 592 | + logger.debug("Failed to get device memory info", exc_info=True) | ||
| 593 | + return None | ||
| 594 | + if total_bytes <= 0: | ||
| 595 | + return None | ||
| 596 | + usage_ratio = 1.0 - (float(free_bytes) / float(total_bytes)) | ||
| 597 | + return min(max(usage_ratio, 0.0), 1.0) | ||
| 598 | + | ||
| 599 | + def _value_at(self, values, index: int, default=None): | ||
| 600 | + if isinstance(values, (list, tuple)) and index < len(values): | ||
| 601 | + return values[index] | ||
| 602 | + return default | ||
| 603 | + | ||
| 604 | + | ||
| 605 | +# --------------------------------------------------------------------------- | ||
| 606 | +# Update Worker | ||
| 607 | +# --------------------------------------------------------------------------- | ||
| 608 | + | ||
| 609 | + | ||
| 610 | +class GearUpdateWorker: | ||
| 611 | + """Asynchronous worker that executes gear update decisions. | ||
| 612 | + | ||
| 613 | + Runs inside the worker thread pool. ``run_once`` performs scoring, eviction | ||
| 614 | + candidate selection, addition candidate generation, and commit -- all under | ||
| 615 | + the commit lock. After the lock is released, calls | ||
| 616 | + GraphResourcePool.remove_by_shapes to safely clean up evicted gear paths. | ||
| 617 | + | ||
| 618 | + Parameters: | ||
| 619 | + runtime: The owning AdaptiveGearRuntime instance. Provides access to the | ||
| 620 | + snapshot store, sample lock, config, scorer, and device_index. | ||
| 621 | + """ | ||
| 622 | + | ||
| 623 | + def __init__(self, runtime: AdaptiveGearRuntime) -> None: | ||
__init__方法参数缺少类型和含义说明,建议对每个参数添加注释说明 ![]() ![]() | |||
| 624 | + self.runtime = runtime | ||
| 625 | + self.config = runtime.config | ||
| 626 | + self.scorer = runtime.scorer | ||
| 627 | + | ||
| 628 | + def run_once(self, now_ts: float): | ||
run_once方法注释过于简单,未说明执行周期和触发条件 ![]() ![]() | |||
| 629 | + """Execute one cycle of the adaptive gear update algorithm. | ||
| 630 | + | ||
| 631 | + Called periodically by the daemon thread at an interval controlled by | ||
| 632 | + ``update_interval_seconds`` (default: 60.0 s). Each cycle: | ||
| 633 | + | ||
| 634 | + 1. Acquires the commit lock and snapshots the current state. | ||
| 635 | + 2. Computes time-decayed stats, scores each gear, and selects eviction | ||
| 636 | + and addition candidates. | ||
| 637 | + 3. Applies the update via ``commit_update``, which builds a new snapshot | ||
| 638 | + and publishes it atomically. | ||
| 639 | + 4. Releases the lock, then cleans up any evicted graph resources via | ||
| 640 | + ``GraphResourcePool.remove_by_shapes``. | ||
| 641 | + | ||
| 642 | + The call is a no-op when there are no candidate changes to apply. | ||
| 643 | + """ | ||
| 644 | + removed_keys: List[int] = [] | ||
| 645 | + with self.runtime._commit_lock: | ||
| 646 | + snapshot = self.runtime.get_snapshot() | ||
| 647 | + stats_snapshot = self.runtime.build_stats_snapshot(now_ts) | ||
| 648 | + score_breakdowns = self.scorer.build_score_breakdown(snapshot, stats_snapshot) | ||
| 649 | + candidate_evictions = self.build_eviction_candidates(score_breakdowns, snapshot, stats_snapshot, now_ts) | ||
| 650 | + candidate_additions = self.build_addition_candidates(snapshot, stats_snapshot) | ||
| 651 | + resource_budget = self.runtime.build_resource_budget() | ||
| 652 | + result = self.commit_update( | ||
| 653 | + snapshot, stats_snapshot, | ||
| 654 | + candidate_evictions, candidate_additions, resource_budget, now_ts, | ||
| 655 | + ) | ||
| 656 | + if result is not None: | ||
| 657 | + _, removed_keys = result | ||
| 658 | + | ||
| 659 | + if removed_keys: | ||
| 660 | + self._cleanup_graphs(removed_keys, self.runtime.device_index) | ||
| 661 | + return result | ||
| 662 | + | ||
| 663 | + | ||
| 664 | + def _cleanup_graphs(keys: List[int], device_index: int) -> None: | ||
| 665 | + """Release graph resources associated with evicted gears. | ||
| 666 | + | ||
| 667 | + Pool keys (``(model_sig, tensor_sigs)`` tuples) are looked up in the | ||
| 668 | + per-device ``GraphResourcePool`` and removed. For each matched | ||
| 669 | + resource the pool calls ``release()`` (tree mode — ``NPUGraphNode``, | ||
| 670 | + which resets the underlying NPU graph and recycles block pointers) or | ||
| 671 | + ``reset()`` (simple mode — ``torch.npu.NPUGraph``). The | ||
| 672 | + ``torch.npu.synchronize()`` call before each release ensures all | ||
| 673 | + outstanding device work on those graphs has completed, preventing | ||
| 674 | + use-after-free. | ||
| 675 | + | ||
| 676 | + Parameters: | ||
| 677 | + keys: Pool-key tuples previously registered via | ||
| 678 | + ``GraphResourcePool.register``. | ||
| 679 | + device_index: NPU device whose pool should be cleaned up. Must | ||
| 680 | + match the device on which the graphs were originally recorded. | ||
| 681 | + """ | ||
| 682 | + GraphResourcePool.get_pool(device_index).remove_by_keys(keys) | ||
| 683 | + | ||
| 684 | + def build_eviction_candidates( | ||
| 685 | + self, | ||
| 686 | + breakdowns: Dict[str, ScoreBreakdown], | ||
| 687 | + snapshot: GearSnapshot, | ||
| 688 | + stats_snapshot: Dict[str, Dict], | ||
| 689 | + now_ts: float, | ||
| 690 | + ) -> Dict[str, str]: | ||
| 691 | + max_gear_by_type = { | ||
| 692 | + shape_type: max(values) | ||
| 693 | + for shape_type, values in snapshot.active_gears.items() | ||
| 694 | + if values | ||
| 695 | + } | ||
| 696 | + candidates_by_type: Dict[str, List[Tuple[float, str]]] = {} | ||
| 697 | + for gear_id, breakdown in breakdowns.items(): | ||
| 698 | + if breakdown.gear_value == max_gear_by_type.get(breakdown.shape_type): | ||
| 699 | + continue | ||
| 700 | + stat = stats_snapshot.get( | ||
| 701 | + gear_id, | ||
| 702 | + { | ||
| 703 | + "created_ts": 0.0, | ||
| 704 | + "last_hit_ts": 0.0, | ||
| 705 | + }, | ||
| 706 | + ) | ||
| 707 | + protect_anchor_ts = max(stat.get("created_ts", 0.0), stat.get("last_hit_ts", 0.0)) | ||
| 708 | + if now_ts - protect_anchor_ts < self.config["recent_use_protect_seconds"]: | ||
| 709 | + continue | ||
| 710 | + if len(snapshot.active_gears.get(breakdown.shape_type, [])) <= self.config["min_gear_count_per_type"]: | ||
| 711 | + continue | ||
| 712 | + candidates_by_type.setdefault(breakdown.shape_type, []).append((breakdown.score, gear_id)) | ||
| 713 | + selected_candidates = {} | ||
| 714 | + for shape_type, candidates in candidates_by_type.items(): | ||
| 715 | + candidates.sort(key=lambda item: item[0]) | ||
| 716 | + selected_candidates[shape_type] = candidates[0][1] | ||
| 717 | + return selected_candidates | ||
| 718 | + | ||
| 719 | + def build_addition_candidates( | ||
| 720 | + self, | ||
| 721 | + snapshot: GearSnapshot, | ||
| 722 | + stats_snapshot: Dict[str, Dict], | ||
| 723 | + ) -> List[Tuple[float, int, str, int]]: | ||
| 724 | + max_gear_by_type = { | ||
| 725 | + shape_type: max(values) | ||
| 726 | + for shape_type, values in snapshot.active_gears.items() | ||
| 727 | + if values | ||
| 728 | + } | ||
| 729 | + candidates: List[Tuple[float, int, str, int]] = [] | ||
| 730 | + | ||
| 731 | + for stat in stats_snapshot.values(): | ||
| 732 | + shape_type = stat["shape_type"] | ||
| 733 | + gear_value = stat["gear_value"] | ||
| 734 | + active_gears = set(snapshot.active_gears.get(shape_type, [])) | ||
| 735 | + raw_samples = stat.get("raw_samples", []) | ||
| 736 | + if not raw_samples: | ||
| 737 | + continue | ||
| 738 | + is_max_gear = gear_value == max_gear_by_type.get(shape_type) | ||
| 739 | + | ||
| 740 | + if is_max_gear: | ||
| 741 | + enough_split = stat.get("split_sample_count", 0) >= self.config["add_min_samples"] | ||
| 742 | + high_split = stat["avg_split_ratio"] >= self.config["split_add_threshold"] | ||
| 743 | + if enough_split and high_split: | ||
| 744 | + g_new = int(statistics.median(raw_samples)) | ||
| 745 | + if g_new not in active_gears: | ||
| 746 | + pressure = stat["avg_split_ratio"] - self.config["split_add_threshold"] | ||
| 747 | + candidates.append((pressure, stat["sample_count"], shape_type, g_new)) | ||
| 748 | + | ||
| 749 | + enough_pad = stat.get("pad_sample_count", 0) >= self.config["add_min_samples"] | ||
| 750 | + high_pad = stat["avg_pad_ratio"] >= self.config["pad_add_threshold"] | ||
| 751 | + if enough_pad and high_pad: | ||
| 752 | + g_new = int(statistics.median(raw_samples)) | ||
| 753 | + if g_new not in active_gears: | ||
| 754 | + pressure = stat["avg_pad_ratio"] - self.config["pad_add_threshold"] | ||
| 755 | + candidates.append((pressure, stat["sample_count"], shape_type, g_new)) | ||
| 756 | + | ||
| 757 | + candidates.sort(key=lambda x: (x[0], x[1]), reverse=True) | ||
| 758 | + return candidates | ||
| 759 | + | ||
| 760 | + def commit_update( | ||
| 761 | + self, | ||
| 762 | + snapshot: GearSnapshot, | ||
| 763 | + stats_snapshot: Dict[str, Dict], | ||
| 764 | + candidate_evictions: Dict[str, str], | ||
| 765 | + candidate_additions: List[Tuple[float, int, str, int]], | ||
| 766 | + resource_budget: Dict[str, Any], | ||
| 767 | + now_ts: float, | ||
| 768 | + ) -> Optional[Tuple[Optional[GearSnapshot], List[Tuple[Tuple[int, ...], ...]]]]: | ||
| 769 | + resource_pressure_high = bool(resource_budget.get("device_memory_usage_high", False)) | ||
| 770 | + next_configs = copy.deepcopy(snapshot.handler_configs) | ||
| 771 | + next_active_gears = { | ||
| 772 | + shape_type: set(values) | ||
| 773 | + for shape_type, values in snapshot.active_gears.items() | ||
| 774 | + } | ||
| 775 | + removed_gears: List[str] = [] | ||
| 776 | + added = False | ||
| 777 | + | ||
| 778 | + for shape_type, gear_id in candidate_evictions.items(): | ||
| 779 | + gear_value = int(gear_id.split(":", 1)[1]) | ||
| 780 | + replace_loss, _ = self.scorer.compute_replace_loss( | ||
| 781 | + snapshot, | ||
| 782 | + stats_snapshot, | ||
| 783 | + gear_id, | ||
| 784 | + ) | ||
| 785 | + if replace_loss > self.config["replace_loss_threshold"]: | ||
| 786 | + self.runtime.protect_gear_from_eviction(gear_id, now_ts) | ||
| 787 | + continue | ||
| 788 | + next_active_gears[shape_type].discard(gear_value) | ||
| 789 | + removed_gears.append(gear_id) | ||
| 790 | + | ||
| 791 | + addition_budget = None if not resource_pressure_high else len(removed_gears) | ||
| 792 | + for _, _, shape_type, candidate in candidate_additions: | ||
| 793 | + if addition_budget is not None and addition_budget <= 0: | ||
| 794 | + break | ||
| 795 | + if candidate in next_active_gears.setdefault(shape_type, set()): | ||
| 796 | + continue | ||
| 797 | + # Enforce per-type gear count cap to prevent unbounded graph variant growth | ||
| 798 | + # (each gear produces a separate compiled graph, consuming device memory). | ||
| 799 | + if len(next_active_gears.get(shape_type, set())) >= self.config["max_gears_per_type"]: | ||
| 800 | + continue | ||
| 801 | + next_active_gears[shape_type].add(candidate) | ||
这里gear总数是否可能超过MAX_GEARS_NUM(64) ![]() ![]() | |||
| 802 | + added = True | ||
| 803 | + if addition_budget is not None: | ||
| 804 | + addition_budget -= 1 | ||
| 805 | + | ||
| 806 | + if not removed_gears and not added: | ||
| 807 | + return None | ||
| 808 | + | ||
| 809 | + for config in next_configs: | ||
| 810 | + active_values = sorted(next_active_gears.get(config["type"], set())) | ||
| 811 | + config["gears"] = active_values | ||
| 812 | + config["policy"] = "CUSTOM" | ||
| 813 | + | ||
| 814 | + return self.runtime.commit_update(next_configs, removed_gears, now_ts=now_ts) | ||
| 815 | + | ||
| 816 | + | ||
| 817 | +# --------------------------------------------------------------------------- | ||
| 818 | +# Metadata collection utilities | ||
| 819 | +# --------------------------------------------------------------------------- | ||
| 820 | + | ||
| 821 | + | ||
| 822 | +def _resolve_dimension(config: Dict[str, Any], dimensions: List[int], position: int) -> int: | ||
| 823 | + if config.get("type") == "BATCHSIZE": | ||
| 824 | + if len(dimensions) == 0: | ||
| 825 | + return 0 | ||
| 826 | + return dimensions[0] | ||
| 827 | + if len(dimensions) == 0: | ||
| 828 | + return 1 | ||
| 829 | + if position < len(dimensions): | ||
| 830 | + return dimensions[position] | ||
| 831 | + return dimensions[0] | ||
| 832 | + | ||
| 833 | + | ||
| 834 | +def _extract_gear_shapes(tensors: List[torch.Tensor], configs: List[Dict[str, Any]]) -> List[List[Optional[int]]]: | ||
| 835 | + all_shapes: List[List[Optional[int]]] = [] | ||
| 836 | + for config in configs: | ||
| 837 | + indices = config.get("indices", []) | ||
| 838 | + dimensions = config.get("dimensions", []) | ||
| 839 | + target_indices = indices if len(indices) > 0 else list(range(len(tensors))) | ||
| 840 | + config_values: List[Optional[int]] = [] | ||
| 841 | + for position, tensor_index in enumerate(target_indices): | ||
| 842 | + if tensor_index >= len(tensors): | ||
| 843 | + config_values.append(None) | ||
| 844 | + continue | ||
| 845 | + tensor = tensors[tensor_index] | ||
| 846 | + dimension = _resolve_dimension(config, dimensions, position) | ||
| 847 | + if tensor.ndim > dimension: | ||
| 848 | + config_values.append(tensor.shape[dimension]) | ||
| 849 | + else: | ||
| 850 | + config_values.append(None) | ||
| 851 | + all_shapes.append(config_values) | ||
| 852 | + return all_shapes | ||
| 853 | + | ||
| 854 | + | ||
| 855 | +def collect_transform_metadata( | ||
这里使用的configs应该是用户传入的顺序, 可能是[SEQLEN, BATCHSIZE], AdaptiveGearRuntime中会按shape_type 进行排序,然后再记录, 看起来会导致错位 ![]() ![]() | |||
| 856 | + inputs: List[torch.Tensor], | ||
| 857 | + trans_outputs: List[List[torch.Tensor]], | ||
| 858 | + configs: List[Dict[str, Any]], | ||
| 859 | +) -> List[Dict[str, Any]]: | ||
| 860 | + """Collect per-variant pad / split metadata for adaptive gear recording. | ||
| 861 | + | ||
| 862 | + The *cleanup_key* is no longer constructed here — it is obtained from | ||
| 863 | + ``GraphResourcePool.consume_recent_keys()`` after ``src_fn`` returns. | ||
| 864 | + """ | ||
| 865 | + # Sort configs by type to match AdaptiveGearRuntime._ordered_configs(). | ||
| 866 | + sorted_configs = sorted(configs, key=lambda c: c["type"]) | ||
| 867 | + raw_gear_values = _extract_gear_shapes(inputs, sorted_configs) | ||
| 868 | + metadata = [] | ||
| 869 | + for output_group in trans_outputs: | ||
| 870 | + mapped_gear_values = _extract_gear_shapes(output_group, sorted_configs) | ||
| 871 | + pad_ratios: List[List[float]] = [] | ||
| 872 | + split_ratios: List[List[float]] = [] | ||
| 873 | + for raw_per_cfg, mapped_per_cfg in zip(raw_gear_values, mapped_gear_values): | ||
| 874 | + cfg_pad: List[float] = [] | ||
| 875 | + cfg_split: List[float] = [] | ||
| 876 | + for raw_value, mapped_value in zip(raw_per_cfg, mapped_per_cfg): | ||
| 877 | + if mapped_value is None: | ||
| 878 | + cfg_pad.append(0.0) | ||
| 879 | + cfg_split.append(0.0) | ||
| 880 | + continue | ||
| 881 | + r = raw_value if raw_value is not None else mapped_value | ||
| 882 | + cfg_pad.append(max(mapped_value - r, 0) / max(mapped_value, 1)) | ||
| 883 | + cfg_split.append(max(r - mapped_value, 0) / max(r, 1)) | ||
| 884 | + pad_ratios.append(cfg_pad) | ||
| 885 | + split_ratios.append(cfg_split) | ||
| 886 | + | ||
| 887 | + metadata.append( | ||
| 888 | + { | ||
| 889 | + "raw_gear_values": raw_gear_values, | ||
| 890 | + "mapped_gear_values": mapped_gear_values, | ||
| 891 | + "pad_ratios": pad_ratios, | ||
| 892 | + "split_ratios": split_ratios, | ||
| 893 | + } | ||
| 894 | + ) | ||
| 895 | + return metadata | ||
| @@ -1,13 +1,17 @@ | |||
| 1 | -__all__ = ["NPUShapeHandling"] | 1 | +__all__ = ["NPUShapeHandling", "unified_copy"] |
| 2 | 2 | ||
| 3 | from typing import Any, Callable, Dict, List, Optional, Tuple | 3 | from typing import Any, Callable, Dict, List, Optional, Tuple |
| 4 | import copy | 4 | import copy |
| 5 | import logging | 5 | import logging |
| 6 | +import sys | ||
| 7 | +import time | ||
| 6 | import warnings | 8 | import warnings |
| 7 | from torch.utils._pytree import tree_flatten, tree_unflatten, TreeSpec | 9 | from torch.utils._pytree import tree_flatten, tree_unflatten, TreeSpec |
| 8 | import torch | 10 | import torch |
| 9 | import torch_npu._C | 11 | import torch_npu._C |
| 10 | 12 | ||
| 13 | +logger = logging.getLogger(__name__) | ||
| 14 | + | ||
| 11 | 15 | ||
| 12 | class NPUShapeHandling(torch_npu._C._NPUShapeHandling): | 16 | class NPUShapeHandling(torch_npu._C._NPUShapeHandling): |
| 13 | r"""Wrapper around a NPU shape handling configuration. | 17 | r"""Wrapper around a NPU shape handling configuration. |
| @@ -47,6 +51,7 @@ class NPUShapeHandling(torch_npu._C._NPUShapeHandling): | |||
| 47 | def __init__( | 51 | def __init__( |
| 48 | self, | 52 | self, |
| 49 | configs: List[Dict[str, Any]] = None, | 53 | configs: List[Dict[str, Any]] = None, |
| 54 | + adaptive_configs: Optional[Dict[str, Any]] = None, | ||
| 50 | transform_pre_fn: Optional[Callable[..., List[torch.Tensor]]] = None, | 55 | transform_pre_fn: Optional[Callable[..., List[torch.Tensor]]] = None, |
| 51 | transform_post_fn: Optional[Callable[[List[List[torch.Tensor]]], Tuple[List[Tuple], List[Dict]]]] = None, | 56 | transform_post_fn: Optional[Callable[[List[List[torch.Tensor]]], Tuple[List[Tuple], List[Dict]]]] = None, |
| 52 | recover_pre_fn: Optional[Callable[[List[Any]], List[List[torch.Tensor]]]] = None, | 57 | recover_pre_fn: Optional[Callable[[List[Any]], List[List[torch.Tensor]]]] = None, |
| @@ -69,6 +74,8 @@ class NPUShapeHandling(torch_npu._C._NPUShapeHandling): | |||
| 69 | self.transform_post_fn = transform_post_fn | 74 | self.transform_post_fn = transform_post_fn |
| 70 | self.recover_pre_fn = recover_pre_fn | 75 | self.recover_pre_fn = recover_pre_fn |
| 71 | self.recover_post_fn = recover_post_fn | 76 | self.recover_post_fn = recover_post_fn |
| 77 | + self.adaptive_configs = adaptive_configs | ||
| 78 | + self._adaptive_manager = None | ||
| 72 | if configs and len(configs) > 0: | 79 | if configs and len(configs) > 0: |
| 73 | self._validate_configs(configs) | 80 | self._validate_configs(configs) |
| 74 | self.configs = configs | 81 | self.configs = configs |
| @@ -85,6 +92,71 @@ class NPUShapeHandling(torch_npu._C._NPUShapeHandling): | |||
| 85 | "max_size": 1024, | 92 | "max_size": 1024, |
| 86 | "policy": "TIMES" | 93 | "policy": "TIMES" |
| 87 | }] | 94 | }] |
| 95 | + if self.adaptive_configs is not None: | ||
| 96 | + self._validate_adaptive_configs(self.adaptive_configs) | ||
| 97 | + self._adaptive_manager = self._create_adaptive_manager() | ||
| 98 | + | ||
| 99 | + | ||
| 100 | + def adaptive_manager(self): | ||
| 101 | + return self._adaptive_manager | ||
| 102 | + | ||
| 103 | + def _validate_adaptive_configs(self, adaptive_configs: Dict[str, Any]) -> None: | ||
| 104 | + from .adaptive_gears import AdaptiveGearRuntime | ||
| 105 | + | ||
| 106 | + _ratio_keys = { | ||
| 107 | + "pad_add_threshold", "split_add_threshold", "replace_loss_threshold", | ||
| 108 | + "weight_hit", "weight_pad", "weight_split", | ||
| 109 | + "device_memory_usage_threshold_ratio", | ||
| 110 | + } | ||
| 111 | + _seconds_keys = { | ||
| 112 | + "window_seconds", "recent_use_protect_seconds", | ||
| 113 | + "update_interval_seconds", | ||
| 114 | + } | ||
| 115 | + _int_keys = { | ||
| 116 | + "min_samples_per_gear", "min_gear_count_per_type", | ||
| 117 | + "add_min_samples", "max_gears_per_type", | ||
| 118 | + } | ||
| 119 | + | ||
| 120 | + valid_keys = set(AdaptiveGearRuntime.DEFAULT_CONFIG.keys()) | ||
| 121 | + for key, value in adaptive_configs.items(): | ||
| 122 | + if key not in valid_keys: | ||
| 123 | + raise ValueError( | ||
| 124 | + f"Unknown adaptive config key: '{key}'. " | ||
| 125 | + f"Valid keys: {sorted(valid_keys)}" | ||
| 126 | + ) | ||
| 127 | + if key in _ratio_keys: | ||
| 128 | + if not isinstance(value, (int, float)) or not (0.0 <= value <= 1.0): | ||
| 129 | + raise ValueError( | ||
| 130 | + f"adaptive config '{key}' must be a number in [0, 1], got {value!r}" | ||
| 131 | + ) | ||
| 132 | + elif key in _seconds_keys: | ||
| 133 | + if not isinstance(value, (int, float)) or value < 0: | ||
| 134 | + raise ValueError( | ||
| 135 | + f"adaptive config '{key}' must be >= 0, got {value!r}" | ||
| 136 | + ) | ||
| 137 | + elif key in _int_keys: | ||
| 138 | + if not isinstance(value, int) or value < 1: | ||
| 139 | + raise ValueError( | ||
| 140 | + f"adaptive config '{key}' must be a positive int, got {value!r}" | ||
| 141 | + ) | ||
| 142 | + | ||
| 143 | + def _create_adaptive_manager(self): | ||
| 144 | + from .adaptive_gears import AdaptiveGearRuntime | ||
| 145 | + | ||
| 146 | + return AdaptiveGearRuntime( | ||
| 147 | + shape_handling_configs=self.configs, | ||
| 148 | + adaptive_configs=self.adaptive_configs, | ||
| 149 | + shape_handling_builder=self._build_adaptive_snapshot_handler, | ||
| 150 | + ) | ||
| 151 | + | ||
| 152 | + def _build_adaptive_snapshot_handler(self, shape_configs: List[Dict[str, Any]]): | ||
| 153 | + return NPUShapeHandling( | ||
| 154 | + configs=shape_configs, | ||
| 155 | + transform_pre_fn=self.transform_pre_fn, | ||
| 156 | + transform_post_fn=self.transform_post_fn, | ||
| 157 | + recover_pre_fn=self.recover_pre_fn, | ||
| 158 | + recover_post_fn=self.recover_post_fn, | ||
| 159 | + ) | ||
| 88 | 160 | ||
| 89 | def _validate_configs(self, configs: List[Dict[str, Any]]) -> None: | 161 | def _validate_configs(self, configs: List[Dict[str, Any]]) -> None: |
| 90 | if not configs or len(configs) == 0: | 162 | if not configs or len(configs) == 0: |
| @@ -237,37 +309,70 @@ class NPUShapeHandling(torch_npu._C._NPUShapeHandling): | |||
| 237 | *args: Any, | 309 | *args: Any, |
| 238 | **kwargs: Any | 310 | **kwargs: Any |
| 239 | ) -> Tuple[List[Tuple], List[Dict]]: | 311 | ) -> Tuple[List[Tuple], List[Dict]]: |
| 240 | - # 获取 logger | 312 | + outputs, _, _ = self._transform_with_context(*args, **kwargs) |
| 241 | - logger = logging.getLogger(__name__) | 313 | + return outputs |
| 242 | - # 预处理阶段优化:统一使用预定义函数或默认逻辑 | 314 | + |
| 315 | + def _transform_with_metadata( | ||
| 316 | + self, | ||
| 317 | + *args: Any, | ||
| 318 | + **kwargs: Any | ||
| 319 | + ) -> Tuple[Tuple[List[Tuple], List[Dict]], List[Dict[str, Any]]]: | ||
| 320 | + """Like :meth:`transform_hook`, but also collects per-gear metadata. | ||
| 321 | + | ||
| 322 | + After shape transformation, this method extracts the raw (pre-transform) | ||
| 323 | + and mapped (post-transform) dimension values for every tensor / config | ||
| 324 | + pair, computes per-tensor pad and split ratios, and packages them | ||
| 325 | + together with ``cleanup_shapes`` (the post-transform tensor shapes used | ||
| 326 | + for graph resource pool indexing) into a list of metadata dicts — one | ||
| 327 | + per gear variant produced by the transform. | ||
| 328 | + | ||
| 329 | + The metadata is consumed by :meth:`AdaptiveGearRuntime.record_event` to | ||
| 330 | + build the time-decayed statistics that drive gear eviction / addition | ||
| 331 | + decisions. | ||
| 332 | + | ||
| 333 | + Failures in metadata collection are caught and logged; an empty metadata | ||
| 334 | + list is returned in that case so that the core inference path is never | ||
| 335 | + blocked. | ||
| 336 | + """ | ||
| 337 | + from .adaptive_gears import collect_transform_metadata | ||
| 338 | + | ||
| 339 | + outputs, inputs, trans_outputs = self._transform_with_context(*args, **kwargs) | ||
| 340 | + try: | ||
| 341 | + metadata = collect_transform_metadata(inputs, trans_outputs, self.configs) | ||
| 342 | + except Exception: | ||
| 343 | + logger.warning("Failed to collect transform metadata, adaptive gears degraded", exc_info=True) | ||
| 344 | + metadata = [] | ||
| 345 | + return outputs, metadata | ||
| 346 | + | ||
| 347 | + def _transform_with_context( | ||
建议提取公共逻辑,减少代码重复 ![]() ![]() | |||
| 348 | + self, | ||
| 349 | + *args: Any, | ||
| 350 | + **kwargs: Any | ||
| 351 | + ) -> Tuple[Tuple[List[Tuple], List[Dict]], List[torch.Tensor], List[List[torch.Tensor]]]: | ||
| 243 | if self.transform_pre_fn: | 352 | if self.transform_pre_fn: |
| 244 | inputs = self.transform_pre_fn(*args, **kwargs) | 353 | inputs = self.transform_pre_fn(*args, **kwargs) |
| 245 | else: | 354 | else: |
| 246 | inputs, indices, leaves, spec = self._process_inputs(args, kwargs) | 355 | inputs, indices, leaves, spec = self._process_inputs(args, kwargs) |
| 247 | - | 356 | + |
| 248 | - # 提取转换前的形状 (inputs 通常是 Tensor 列表) | ||
| 249 | if logger.isEnabledFor(logging.INFO): | 357 | if logger.isEnabledFor(logging.INFO): |
| 250 | pre_shapes = [self.get_shape_safe(t) for t in inputs] | 358 | pre_shapes = [self.get_shape_safe(t) for t in inputs] |
| 251 | logger.info(f"[Transform] Starting. Input tensors: {len(inputs)}, Shapes: {pre_shapes}") | 359 | logger.info(f"[Transform] Starting. Input tensors: {len(inputs)}, Shapes: {pre_shapes}") |
| 252 | 360 | ||
| 253 | - # 执行核心转换操作 | ||
| 254 | trans_outputs = self.transform(tensors=inputs) | 361 | trans_outputs = self.transform(tensors=inputs) |
| 255 | 362 | ||
| 256 | - # 提取转换后的形状 | ||
| 257 | if logger.isEnabledFor(logging.INFO): | 363 | if logger.isEnabledFor(logging.INFO): |
| 258 | post_shapes = [self.get_shape_safe(t) for t in trans_outputs] | 364 | post_shapes = [self.get_shape_safe(t) for t in trans_outputs] |
| 259 | logger.info(f"> Post-transform content: {post_shapes}") | 365 | logger.info(f"> Post-transform content: {post_shapes}") |
| 260 | - | 366 | + |
| 261 | - # 后处理阶段优化:避免嵌套循环 | ||
| 262 | if self.transform_post_fn: | 367 | if self.transform_post_fn: |
| 263 | outputs = self.transform_post_fn(trans_outputs) | 368 | outputs = self.transform_post_fn(trans_outputs) |
| 264 | else: | 369 | else: |
| 265 | outputs = self._recover_inputs(trans_outputs, indices, leaves, spec) | 370 | outputs = self._recover_inputs(trans_outputs, indices, leaves, spec) |
| 266 | - | 371 | + |
| 267 | if not outputs: | 372 | if not outputs: |
| 268 | logger.error(f"CRITICAL: _recover_inputs returned NULL") | 373 | logger.error(f"CRITICAL: _recover_inputs returned NULL") |
| 269 | - | 374 | + |
| 270 | - return outputs | 375 | + return outputs, inputs, trans_outputs |
| 271 | 376 | ||
| 272 | def flatten_to_tensors(self, structure: Any) -> Tuple[List[torch.Tensor], List[int], List[Any], TreeSpec]: | 377 | def flatten_to_tensors(self, structure: Any) -> Tuple[List[torch.Tensor], List[int], List[Any], TreeSpec]: |
| 273 | leaves, spec = tree_flatten(structure) | 378 | leaves, spec = tree_flatten(structure) |
| @@ -302,7 +407,10 @@ class NPUShapeHandling(torch_npu._C._NPUShapeHandling): | |||
| 302 | res = [] | 407 | res = [] |
| 303 | for processd_tensors in transform_res: | 408 | for processd_tensors in transform_res: |
| 304 | res.append(self.unflatten_from_tensors(processd_tensors, indices, list(leaves), spec)) | 409 | res.append(self.unflatten_from_tensors(processd_tensors, indices, list(leaves), spec)) |
| 305 | - return zip(*res) | 410 | + if not res: |
| 411 | + return [], [] | ||
| 412 | + args_list, kwargs_list = zip(*res) | ||
| 413 | + return list(args_list), list(kwargs_list) | ||
| 306 | 414 | ||
| 307 | def _process_outputs( | 415 | def _process_outputs( |
| 308 | self, | 416 | self, |
| @@ -458,15 +566,18 @@ def patch_dynamo_context(): | |||
| 458 | trans_post_fn = None | 566 | trans_post_fn = None |
| 459 | re_pre_fn = None | 567 | re_pre_fn = None |
| 460 | re_post_fn = None | 568 | re_post_fn = None |
| 569 | + adaptive_configs = None | ||
| 461 | function_dict = compiler_config.get("shape_handling_dict") | 570 | function_dict = compiler_config.get("shape_handling_dict") |
| 462 | if function_dict is not None: | 571 | if function_dict is not None: |
| 463 | trans_pre_fn = function_dict.get("trans_pre_fn", None) | 572 | trans_pre_fn = function_dict.get("trans_pre_fn", None) |
| 464 | trans_post_fn = function_dict.get("trans_post_fn", None) | 573 | trans_post_fn = function_dict.get("trans_post_fn", None) |
| 465 | re_pre_fn = function_dict.get("re_pre_fn", None) | 574 | re_pre_fn = function_dict.get("re_pre_fn", None) |
| 466 | re_post_fn = function_dict.get("re_post_fn", None) | 575 | re_post_fn = function_dict.get("re_post_fn", None) |
| 576 | + adaptive_configs = function_dict.get("adaptive_gears", None) | ||
| 467 | 577 | ||
| 468 | self.shape_handling = NPUShapeHandling( | 578 | self.shape_handling = NPUShapeHandling( |
| 469 | configs=compiler_config.get("shape_handling_configs"), | 579 | configs=compiler_config.get("shape_handling_configs"), |
| 580 | + adaptive_configs=adaptive_configs, | ||
| 470 | transform_pre_fn=trans_pre_fn, | 581 | transform_pre_fn=trans_pre_fn, |
| 471 | transform_post_fn=trans_post_fn, | 582 | transform_post_fn=trans_post_fn, |
| 472 | recover_pre_fn=re_pre_fn, | 583 | recover_pre_fn=re_pre_fn, |
| @@ -477,19 +588,66 @@ def patch_dynamo_context(): | |||
| 477 | src_fn = src_call(self, fn) | 588 | src_fn = src_call(self, fn) |
| 478 | if isinstance(fn, torch.nn.Module) or inspect.isclass(fn): | 589 | if isinstance(fn, torch.nn.Module) or inspect.isclass(fn): |
| 479 | return src_fn | 590 | return src_fn |
| 480 | - | 591 | + |
| 481 | def new_fn(*args, **kwargs): | 592 | def new_fn(*args, **kwargs): |
| 482 | if (is_enable_shape_handling(self.callback, compiler_config=self.compiler_config)): | 593 | if (is_enable_shape_handling(self.callback, compiler_config=self.compiler_config)): |
| 483 | - new_args, new_kwargs = self.shape_handling.transform_hook(*args, **kwargs) | 594 | + sh = self.shape_handling |
| 484 | - args_is_split = len(args) != 0 and len(new_args) > 1 | 595 | + manager = sh.adaptive_manager |
| 485 | - kwargs_is_split = len(kwargs) != 0 and len(new_kwargs) > 1 | 596 | + snapshot = manager.get_snapshot() if manager else None |
| 486 | - zipped_params = zip(new_args, new_kwargs) | 597 | + target_sh = snapshot.shape_handling if snapshot else sh |
| 487 | - res = [ | 598 | + |
| 488 | - unified_copy(src_fn(*arg, **kwargs)) if args_is_split or kwargs_is_split | 599 | + pool = None |
| 489 | - else src_fn(*arg, **kwargs) | 600 | + try: |
| 490 | - for arg, kwargs in zipped_params | 601 | + if manager: |
| 491 | - ] | 602 | + from torch_npu.npu._graph_resource_pool import GraphResourcePool |
| 492 | - return self.shape_handling.recover_hook(res) | 603 | + pool = GraphResourcePool.get_pool(torch.npu.current_device()) |
| 604 | + pool.activate() | ||
| 605 | + (new_args, new_kwargs), metadata = target_sh._transform_with_metadata(*args, **kwargs) | ||
| 606 | + else: | ||
| 607 | + new_args, new_kwargs = target_sh.transform_hook(*args, **kwargs) | ||
| 608 | + metadata = [] | ||
| 609 | + args_is_split = len(args) != 0 and len(new_args) > 1 | ||
| 610 | + kwargs_is_split = len(kwargs) != 0 and len(new_kwargs) > 1 | ||
| 611 | + res = [] | ||
| 612 | + for index, (call_args, call_kwargs) in enumerate(zip(new_args, new_kwargs)): | ||
| 613 | + raised_exc = None | ||
| 614 | + try: | ||
| 615 | + result = ( | ||
| 616 | + unified_copy(src_fn(*call_args, **call_kwargs)) | ||
| 617 | + if args_is_split or kwargs_is_split | ||
| 618 | + else src_fn(*call_args, **call_kwargs) | ||
| 619 | + ) | ||
| 620 | + except Exception: | ||
| 621 | + raised_exc = sys.exc_info()[0] | ||
| 622 | + raise | ||
| 623 | + finally: | ||
| 624 | + if pool is not None: | ||
| 625 | + # Consume graph keys registered during this | ||
| 626 | + # src_fn invocation (pool.activate was called). | ||
| 627 | + keys = pool.consume_recent_keys() | ||
| 628 | + if raised_exc is not None: | ||
| 629 | + if keys: | ||
| 630 | + pool.remove_by_keys(keys) | ||
| 631 | + elif manager and snapshot and index < len(metadata): | ||
| 632 | + meta = metadata[index] | ||
| 633 | + for key in (keys or [None]): | ||
| 634 | + try: | ||
| 635 | + manager.record_event( | ||
| 636 | + raw_gear_values=meta["raw_gear_values"], | ||
| 637 | + mapped_gear_values=meta["mapped_gear_values"], | ||
| 638 | + pad_ratios=meta["pad_ratios"], | ||
| 639 | + split_ratios=meta["split_ratios"], | ||
| 640 | + event_ts=time.time(), | ||
| 641 | + cleanup_key=key, | ||
| 642 | + ) | ||
| 643 | + except Exception: | ||
| 644 | + logger.warning("Failed to record adaptive gear event", exc_info=True) | ||
| 645 | + res.append(result) | ||
| 646 | + output = target_sh.recover_hook(res) | ||
| 647 | + return output | ||
| 648 | + finally: | ||
| 649 | + if pool is not None: | ||
| 650 | + pool.deactivate() | ||
| 493 | return src_fn(*args, **kwargs) | 651 | return src_fn(*args, **kwargs) |
| 494 | return new_fn | 652 | return new_fn |
| 495 | _TorchDynamoContext.__call__ = new_call | 653 | _TorchDynamoContext.__call__ = new_call |
| @@ -0,0 +1,166 @@ | |||
| 1 | +from __future__ import annotations | ||
| 2 | + | ||
| 3 | +import threading | ||
| 4 | +from typing import Any, Dict, List | ||
| 5 | + | ||
| 6 | +import torch | ||
| 7 | + | ||
| 8 | + | ||
| 9 | +class GraphResourcePool: | ||
| 10 | + """Per-device graph resource registry with opaque integer keys. | ||
| 11 | + | ||
| 12 | + Each graph resource (``NPUGraphNode`` in tree mode, ``torch.npu.NPUGraph`` | ||
| 13 | + in simple mode) is registered with an auto-incrementing integer key. The | ||
| 14 | + key is a pure lookup handle — it carries no semantic information. | ||
| 15 | + | ||
| 16 | + Two kinds of resources are supported, corresponding to the two graph | ||
| 17 | + compilation modes: | ||
| 18 | + | ||
| 19 | + * tree mode — ``NPUGraphNode`` (has ``release()`` → block pointers) | ||
| 20 | + * simple mode — ``torch.npu.NPUGraph`` (has ``reset()``) | ||
| 21 | + | ||
| 22 | + **Key lifecycle**:: | ||
| 23 | + | ||
| 24 | + # Lower layer (graph capture, inside src_fn): | ||
| 25 | + key = pool.register(node) # → opaque int | ||
| 26 | + | ||
| 27 | + # Upper layer (shape_handling, after src_fn returns): | ||
| 28 | + keys = pool.consume_recent_keys() # → List[int] | ||
| 29 | + | ||
| 30 | + # Background (gear eviction): | ||
| 31 | + pool.remove_by_keys([42, 43]) # idempotent | ||
| 32 | + | ||
| 33 | + Thread safety | ||
| 34 | + ------------- | ||
| 35 | + All mutations to ``_entries`` and ``_pending_by_thread`` are serialised | ||
| 36 | + under ``self._lock``. The heavy work — ``torch.npu.synchronize`` and | ||
| 37 | + ``resource.release() / reset()`` — is performed **outside** the lock. | ||
| 38 | + | ||
| 39 | + ``register`` and ``consume_recent_keys`` form a per-thread | ||
| 40 | + producer-consumer pair: ``register`` appends keys to the calling thread's | ||
| 41 | + pending list, ``consume_recent_keys`` drains it. This guarantees that | ||
| 42 | + concurrent inferences on different threads never mix their keys. | ||
| 43 | + | ||
| 44 | + ``remove_by_keys`` is idempotent: a key that has already been removed | ||
| 45 | + (by a prior gear eviction) simply results in ``_entries.pop(key, None)`` | ||
| 46 | + returning ``None``, which is skipped. | ||
| 47 | + """ | ||
| 48 | + | ||
| 49 | + _pools: Dict[int, "GraphResourcePool"] = {} | ||
资源池的线程安全保证未在注释中说明,补充说明线程安全范围和锁粒度 ![]() ![]() | |||
| 50 | + | ||
| 51 | + # ---- pool lifecycle ------------------------------------------------- | ||
| 52 | + | ||
| 53 | + | ||
| 54 | + def get_pool(cls, device_index: int) -> "GraphResourcePool": | ||
| 55 | + if device_index not in cls._pools: | ||
| 56 | + cls._pools[device_index] = cls(device_index) | ||
| 57 | + return cls._pools[device_index] | ||
| 58 | + | ||
| 59 | + | ||
| 60 | + def reset_all(cls) -> None: | ||
| 61 | + cls._pools.clear() | ||
| 62 | + | ||
| 63 | + def __init__(self, device_index: int) -> None: | ||
| 64 | + self.device_index = device_index | ||
| 65 | + self._lock = threading.Lock() | ||
| 66 | + self._next_key = 0 | ||
| 67 | + # key → graph resource (1:1 mapping) | ||
| 68 | + self._entries: Dict[int, Any] = {} | ||
| 69 | + # thread-id → keys registered since last consume (producer→consumer) | ||
| 70 | + self._pending_by_thread: Dict[int, List[int]] = {} | ||
| 71 | + # thread-id → activation refcount (0 ≈ not active) | ||
| 72 | + self._active_threads: Dict[int, int] = {} | ||
| 73 | + | ||
| 74 | + # ---- activation (per-thread, refcounted) ---------------------------- | ||
| 75 | + | ||
| 76 | + def activate(self) -> None: | ||
| 77 | + """Allow :meth:`register` on the calling thread. | ||
| 78 | + | ||
| 79 | + Safe to call multiple times — each call must be balanced by a | ||
| 80 | + corresponding :meth:`deactivate`. | ||
| 81 | + """ | ||
| 82 | + tid = threading.get_ident() | ||
| 83 | + with self._lock: | ||
| 84 | + self._active_threads[tid] = self._active_threads.get(tid, 0) + 1 | ||
| 85 | + | ||
| 86 | + def deactivate(self) -> None: | ||
| 87 | + """Revoke one :meth:`activate` on the calling thread. | ||
| 88 | + | ||
| 89 | + When the refcount drops to zero, subsequent :meth:`register` calls | ||
| 90 | + on this thread become no-ops. | ||
| 91 | + """ | ||
| 92 | + tid = threading.get_ident() | ||
| 93 | + with self._lock: | ||
| 94 | + v = self._active_threads.get(tid, 1) - 1 | ||
| 95 | + if v <= 0: | ||
| 96 | + self._active_threads.pop(tid, None) | ||
| 97 | + else: | ||
| 98 | + self._active_threads[tid] = v | ||
| 99 | + | ||
| 100 | + def is_active(self) -> bool: | ||
| 101 | + """Return ``True`` if the calling thread has been activated.""" | ||
| 102 | + tid = threading.get_ident() | ||
| 103 | + with self._lock: | ||
| 104 | + return tid in self._active_threads | ||
| 105 | + | ||
| 106 | + # ---- lower-layer API (graph capture) -------------------------------- | ||
| 107 | + | ||
| 108 | + def register(self, resource: Any) -> int: | ||
| 109 | + """Register a graph resource and return an opaque integer key. | ||
| 110 | + | ||
| 111 | + Called during graph capture from ``record_function`` (tree mode) or | ||
| 112 | + ``npugraphify_impl`` (simple mode). Callers should guard the call | ||
| 113 | + with :meth:`is_active` when registration is conditional on the | ||
| 114 | + adaptive-gear feature being enabled. | ||
| 115 | + """ | ||
| 116 | + tid = threading.get_ident() | ||
| 117 | + with self._lock: | ||
| 118 | + key = self._next_key | ||
| 119 | + self._next_key += 1 | ||
| 120 | + self._entries[key] = resource | ||
| 121 | + self._pending_by_thread.setdefault(tid, []).append(key) | ||
| 122 | + return key | ||
| 123 | + | ||
| 124 | + # ---- upper-layer API (after src_fn returns) ------------------------- | ||
| 125 | + | ||
| 126 | + def consume_recent_keys(self) -> List[int]: | ||
| 127 | + """Return and clear all keys registered on this thread since the | ||
| 128 | + last call to ``consume_recent_keys``. | ||
| 129 | + | ||
| 130 | + Called in ``new_fn`` immediately after ``src_fn`` returns, so the | ||
| 131 | + returned keys correspond exactly to the graph resources captured | ||
| 132 | + during that ``src_fn`` invocation. | ||
| 133 | + """ | ||
| 134 | + tid = threading.get_ident() | ||
| 135 | + with self._lock: | ||
| 136 | + return self._pending_by_thread.pop(tid, []) | ||
| 137 | + | ||
| 138 | + # ---- cleanup API (gear eviction) ------------------------------------ | ||
| 139 | + | ||
| 140 | + def remove_by_keys(self, keys: List[int]) -> None: | ||
| 141 | + """Release graph resources identified by *keys*. | ||
| 142 | + | ||
| 143 | + Idempotent — keys that have already been removed are silently | ||
| 144 | + skipped. Called by ``GearUpdateWorker`` on the background thread | ||
| 145 | + after gear eviction. | ||
| 146 | + """ | ||
| 147 | + to_cleanup: List[Any] = [] | ||
| 148 | + with self._lock: | ||
| 149 | + for key in keys: | ||
| 150 | + resource = self._entries.pop(key, None) | ||
| 151 | + if resource is not None: | ||
| 152 | + to_cleanup.append(resource) | ||
| 153 | + for resource in to_cleanup: | ||
| 154 | + torch.npu.synchronize() | ||
| 155 | + if hasattr(resource, "release"): | ||
| 156 | + resource.release() | ||
| 157 | + else: | ||
| 158 | + resource.reset() | ||
| 159 | + | ||
| 160 | + # ---- debugging ------------------------------------------------------ | ||
| 161 | + | ||
| 162 | + | ||
| 163 | + def entry_count(self) -> int: | ||
| 164 | + """Number of currently registered graph resources (for tests).""" | ||
| 165 | + with self._lock: | ||
| 166 | + return len(self._entries) | ||
| @@ -110,6 +110,7 @@ from torch_npu.npu._aclgraph_update_plan.resolver import ( | |||
| 110 | validate_aclgraph_update_plan_for_graph, | 110 | validate_aclgraph_update_plan_for_graph, |
| 111 | ) | 111 | ) |
| 112 | import torch_npu.npu.aclnn | 112 | import torch_npu.npu.aclnn |
| 113 | +from torch_npu.npu._graph_resource_pool import GraphResourcePool | ||
| 113 | 114 | ||
| 114 | if TYPE_CHECKING: | 115 | if TYPE_CHECKING: |
| 115 | from torch._inductor.utils import InputType | 116 | from torch._inductor.utils import InputType |
| @@ -778,6 +779,7 @@ class NPUGraphNode: | |||
| 778 | self.device = device_index | 779 | self.device = device_index |
| 779 | self.stack_traces = stack_traces | 780 | self.stack_traces = stack_traces |
| 780 | self.stream = stream | 781 | self.stream = stream |
| 782 | + self.function_id: Optional[FunctionID] = None # set when attached to tree | ||
| 781 | 783 | ||
| 782 | # Enable re-record a cudagraph when static tensor address changed. | 784 | # Enable re-record a cudagraph when static tensor address changed. |
| 783 | # if not we should error when it changed. | 785 | # if not we should error when it changed. |
| @@ -1507,6 +1509,7 @@ class NPUGraphNode: | |||
| 1507 | 1509 | ||
| 1508 | def add_child(self, function_id: FunctionID, node: NPUGraphNode) -> None: | 1510 | def add_child(self, function_id: FunctionID, node: NPUGraphNode) -> None: |
| 1509 | "Adds node as a a child of self" | 1511 | "Adds node as a a child of self" |
| 1512 | + node.function_id = function_id | ||
| 1510 | self.children[function_id].append(node) | 1513 | self.children[function_id].append(node) |
| 1511 | 1514 | ||
| 1512 | 1515 | ||
| @@ -1621,12 +1624,70 @@ class NPUGraphNode: | |||
| 1621 | self.cached_tensor_outputs.clear() | 1624 | self.cached_tensor_outputs.clear() |
| 1622 | 1625 | ||
| 1623 | for i, unaliased in enumerate(self.unaliased_in_all_paths): | 1626 | for i, unaliased in enumerate(self.unaliased_in_all_paths): |
| 1624 | - if unaliased: | 1627 | + if unaliased and i < len(self.outputs_weakrefs): |
| 1625 | n = self.outputs_weakrefs[i] | 1628 | n = self.outputs_weakrefs[i] |
| 1626 | if n is None: | 1629 | if n is None: |
| 1627 | raise RuntimeError("check n is not None fail") | 1630 | raise RuntimeError("check n is not None fail") |
| 1628 | n.remove_extra_reference() | 1631 | n.remove_extra_reference() |
| 1629 | 1632 | ||
| 1633 | + def release(self) -> None: | ||
| 1634 | + """Release all resources held by this graph node and detach from the tree. | ||
| 1635 | + | ||
| 1636 | + Release order: | ||
| 1637 | + 1. **Detach from tree** — remove self from ``parent.children`` (or from | ||
| 1638 | + ``tree_manager.roots`` for root nodes) so that subsequent replay | ||
| 1639 | + lookups do not encounter a freed node. | ||
| 1640 | + 2. **Cached tensors** — clear all cached tensor references held for | ||
| 1641 | + graph inputs / outputs (``remove_node_cached_tensors``). | ||
| 1642 | + 3. **Output blocks** — collect unaliased output tensor data pointers | ||
| 1643 | + for later recycling via the caching allocator. | ||
| 1644 | + 4. **NPU graph** — call ``self.graph.reset()`` to free the underlying | ||
| 1645 | + device graph resources, then drop the reference. | ||
| 1646 | + 5. **Weakrefs and recording state** — null out ``outputs_weakrefs``, | ||
| 1647 | + ``tensor_weakrefs``, ``recording_outputs``, and | ||
| 1648 | + ``checkpointed_caching_state`` to break reference cycles. | ||
| 1649 | + 6. **Block pointers** — for each collected unaliased output block, call | ||
| 1650 | + ``raw_delete`` on the caching allocator, returning the memory to the | ||
| 1651 | + free pool. | ||
| 1652 | + | ||
| 1653 | + After this call the node is safe to drop; the NPU graph memory is | ||
| 1654 | + released and the tree structure no longer references this node. | ||
| 1655 | + """ | ||
| 1656 | + # Detach from tree structure to avoid dangling references. | ||
| 1657 | + parent = self.parent | ||
| 1658 | + function_id = self.function_id | ||
| 1659 | + if parent is not None: | ||
| 1660 | + if function_id is not None: | ||
| 1661 | + siblings = parent.children.get(function_id, []) | ||
| 1662 | + if self in siblings: | ||
| 1663 | + siblings.remove(self) | ||
| 1664 | + else: | ||
| 1665 | + # Root node: remove from tree manager's roots dict. | ||
| 1666 | + container = get_container(self.device) | ||
| 1667 | + tree_manager = container.tree_manager if container is not None else None | ||
| 1668 | + if tree_manager is not None and tree_manager.roots is not None: | ||
| 1669 | + if function_id is not None: | ||
| 1670 | + root_list = tree_manager.roots.get(function_id, []) | ||
| 1671 | + if self in root_list: | ||
| 1672 | + root_list.remove(self) | ||
| 1673 | + self.remove_node_cached_tensors() | ||
| 1674 | + blocks_to_recycle = [] | ||
| 1675 | + for i, unaliased in enumerate(self.unaliased_in_all_paths): | ||
| 1676 | + if unaliased and i < len(self.outputs_weakrefs): | ||
| 1677 | + ref = self.outputs_weakrefs[i] | ||
| 1678 | + if ref is not None and ref(): | ||
| 1679 | + blocks_to_recycle.append(ref().data_ptr()) | ||
| 1680 | + if self.graph is not None: | ||
| 1681 | + self.graph.reset() | ||
| 1682 | + self.graph = None | ||
| 1683 | + self.cached_tensor_outputs = [] | ||
| 1684 | + self.outputs_weakrefs = [] | ||
| 1685 | + self.tensor_weakrefs = [] | ||
| 1686 | + self.recording_outputs = None | ||
| 1687 | + self.checkpointed_caching_state = None | ||
| 1688 | + for ptr in set(blocks_to_recycle): | ||
| 1689 | + torch_npu._C._npu_npuCachingAllocator_raw_delete(ptr) | ||
| 1690 | + | ||
| 1630 | def remove_path_cached_tensors(self) -> None: | 1691 | def remove_path_cached_tensors(self) -> None: |
| 1631 | for node in self._path_from_root: | 1692 | for node in self._path_from_root: |
| 1632 | node.remove_node_cached_tensors() | 1693 | node.remove_node_cached_tensors() |
| @@ -2254,6 +2315,7 @@ class NPUGraphTreeManager: | |||
| 2254 | self.stream, | 2315 | self.stream, |
| 2255 | ) | 2316 | ) |
| 2256 | if self.current_node is None: | 2317 | if self.current_node is None: |
| 2318 | + node.function_id = function_id | ||
| 2257 | self.roots[function_id].append(node) | 2319 | self.roots[function_id].append(node) |
| 2258 | else: | 2320 | else: |
| 2259 | self.current_node.add_child(function_id, node) | 2321 | self.current_node.add_child(function_id, node) |
| @@ -2262,6 +2324,9 @@ class NPUGraphTreeManager: | |||
| 2262 | self.update_generation() | 2324 | self.update_generation() |
| 2263 | log.debug("NPUGRAPH-TREE State state=RECORDING, node=%s, gen=%d", | 2325 | log.debug("NPUGRAPH-TREE State state=RECORDING, node=%s, gen=%d", |
| 2264 | graph_id.id, self.current_gen) | 2326 | graph_id.id, self.current_gen) |
| 2327 | + pool = GraphResourcePool.get_pool(self.device_index) | ||
| 2328 | + if pool.is_active(): | ||
| 2329 | + pool.register(node) | ||
| 2265 | torch.npu.synchronize() | 2330 | torch.npu.synchronize() |
| 2266 | return node.run_first_inputs(new_inputs) | 2331 | return node.run_first_inputs(new_inputs) |
| 2267 | 2332 | ||
| @@ -197,6 +197,16 @@ def npugraphify_impl( | |||
| 197 | if not isinstance(static_outputs, (list, tuple)): | 197 | if not isinstance(static_outputs, (list, tuple)): |
| 198 | static_outputs = (static_outputs,) | 198 | static_outputs = (static_outputs,) |
| 199 | 199 | ||
| 200 | + # Register the underlying NPUGraph in the device-level resource pool. | ||
| 201 | + from torch_npu.npu._graph_resource_pool import GraphResourcePool | ||
| 202 | + device_index = next( | ||
| 203 | + (inp.device.index for inp in inputs if isinstance(inp, torch.Tensor)), | ||
| 204 | + 0, | ||
| 205 | + ) | ||
| 206 | + pool = GraphResourcePool.get_pool(device_index) | ||
| 207 | + if pool.is_active(): | ||
| 208 | + pool.register(graph) | ||
| 209 | + | ||
| 200 | if config.size_asserts: | 210 | if config.size_asserts: |
| 201 | 211 | ||
| 202 | def run(new_inputs: List[InputType]) -> Callable[[List[InputType]], Any]: | 212 | def run(new_inputs: List[InputType]) -> Callable[[List[InputType]], Any]: |


配置重复 同1533