[202405][GCU] Backport #3831 perf refactor + #4118/#4335/#4668 fixes (parts 1-8) - #417
[202405][GCU] Backport #3831 perf refactor + #4118/#4335/#4668 fixes (parts 1-8)#417rimunagala wants to merge 10 commits into
Conversation
…r: fetch config once per patch, thread through ChangeApplier Backport of the config-threading portion of upstream sonic-net/sonic-utilities PR #3831 to the 202405 branch, mirroring the 202412 auto-backport (PR Azure#254). Motivation: On 202405 the ChangeApplier.apply() path calls get_config_db_as_json() twice per JsonChange (once to seed the deepcopy, once for the post-apply sanity check). At MOR scale (N=14 => 381 changes) this dominates wall clock time and yields the flat ~1.47s/change floor observed in the sonic-mgmt scaling test. This change: * JsonChange.apply(config) -> JsonChange.apply(config, in_place=False) * DryRunConfigWrapper.apply_change_to_config_db(change) -> apply_change_to_config_db(current_config_db, change) and returns the updated dict. * ChangeApplier.apply(change) -> apply(current_configdb, change) -> dict - drops the deepcopy (uses in_place=False on JsonChange.apply) - drops the redundant post-apply get_config_db_as_json + jsondiff sanity check; replaces it with a 1s sleep race-guard (matches upstream #3831 behavior; upstream SONiC issue tracks proper fix). - returns the updated in-memory config dict. * DryRunChangeApplier.apply likewise threads and returns config. * PatchApplier.apply now fetches old_config once, seeds current_config from it, and threads current_config = self.changeapplier.apply( current_config, change) through the change loop. Tests updated to match new signatures: * change_applier_test.py: mock_obj.apply(config, in_place), applier call sites pass current_config, DryRunChangeApplier test updated. * multiasic_change_applier_test.py: @patch targets moved from change_applier.get_config_db_as_json to gu_common.get_config_db_as_json (since change_applier no longer re-exports it), applier call sites updated, `import copy` added. * generic_updater_test.py: TestPatchApplier now asserts changeapplier.apply.assert_called() and the mock side_effect accepts (current_config, change) and returns the config through. * gu_common_test.py: DryRunConfigWrapper multi-call test threads the returned config through the loop. Deliberately out of scope for this commit (will land in follow-ups): * skip_sort_tables.txt handling (perpetuates a shortcut we want to eliminate on 202412; not required for perf win). * illegal_dataacl_check (comes from upstream PR #3668, not #3831). * PathAddressing xpath cache rewrite and find_ref_paths batch API. * BulkLowLevelMoveGenerator family (patch_sorter additive changes). * extract_scope multi-ASIC guard. Reference: Azure/sonic-utilities.msft PR Azure#254 (202412 auto-backport of upstream sonic-net/sonic-utilities #3831). Signed-off-by: Rithvick Reddy Munagala <rimunagala@microsoft.com>
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
…he + batch find_ref_paths Backport of the gu_common.py PathAddressing portion of upstream sonic-net/sonic-utilities PR #3831 to 202405, mirroring 202412 auto-backport (Azure/sonic-utilities.msft PR Azure#254). Motivation: On 202405, PathAddressing rebuilds its sonic-yang xpath token index from scratch on every conversion call. At MOR scale (14 hosts * 27+ paths per patch = several hundred conversions per apply) this produces the O(N) yang-tree-walk cost observed as the second-largest contributor to GCU wall-clock time after the double get_config_db_as_json addressed in part 1. Also, find_ref_paths(path, ...) took a single path and had to reload config for every call. The batched find_ref_paths(paths, ...) walks config once per batch. This change: * PathAddressing xpath cache rewrite: retire the ~500 lines of _get_xpath_tokens_from_* / _get_path_tokens_from_* walkers + _get_*_model / _find_leafref_paths helpers. Replace with a hash-keyed loadData cache so repeated conversions on the same config skip the yang reload. Public convert_path_to_xpath, convert_xpath_to_path, get_path_tokens, get_xpath_tokens, create_path, has_path, get_from_path, is_config_different signatures unchanged. * find_ref_paths(path, ...) -> find_ref_paths(paths, ...) batch API. Accepts either a single path (str) or a list of paths. * New helpers: configdb_sorted_keys_by_backlinks and configdb_sort_cmp used by patch_sorter's Bulk generators (added in part 3). * DryRunConfigWrapper.apply_change_to_config_db signature retained from part 1 (current_config_db threaded, returns updated dict). * JsonChange.apply(config, in_place=False) retained from part 1. Test file wholesale-replaced from 202412 (adds caching-behavior tests test_validate_config_db_config__same_config_called_twice__loadData_called_once, test_find_ref_paths__after_validate_same_config__loadData_skipped, etc; retires create_xpath / walker tests whose helpers are being deleted). Deliberately excluded (not part of #3831): * ConfigWrapper.illegal_dataacl_check (from upstream PR #3668). Reference: Azure/sonic-utilities.msft PR Azure#254 (202412 auto-backport of upstream sonic-net/sonic-utilities #3831). Signed-off-by: Rithvick Reddy Munagala <rimunagala@microsoft.com>
…oup + Bulk generators + validator refactor Backport of the patch_sorter.py portion of upstream sonic-net/sonic-utilities PR #3831 to 202405, mirroring 202412 auto-backport (Azure/sonic-utilities.msft PR Azure#254). Motivation: On 202405 the move-generation engine emits one JsonMove per individual field/key delta, and each JsonMove goes through full-graph validation independently. At MOR scale (14 hosts) the sorter emits several hundred moves, and validators reload sonic-yang xpath state on every call. Combined with the per-move DB roundtrip removed in part 1, this is the second-largest contributor to observed GCU wall-clock time. This change ports the sorter/validator rewrite: New: * JsonMoveGroup - a mergeable group of JsonMoves. apply()/undo() fold a whole group into config in one pass, in_place-aware. * BulkLowLevelMoveGenerator, BulkKeyLevelMoveGenerator, BulkKeyGroupLowLevelMoveGenerator - emit JsonMoveGroups that coalesce many single-field/key deltas into one add/remove/replace move per subtree, with restricted-only fallback for correctness. * MoveWrapper.apply_move / undo_move now take in_place: bool. * Simulator.simulate / undo_simulate now take in_place: bool. Refactor: * All *MoveValidator.validate signatures unified to (group: JsonMoveGroup, diff, simulated_config) - the simulated config is now computed once by the caller and passed in, instead of each validator recomputing it. * _extend_moves(moveGroup) and target_in_required_pattern helpers. * KeyLevelMoveGenerator / LowLevelMoveGenerator retained but now delegate to the Bulk variants under the hood via generate_groups with restricted_only fallback. * find_ref_paths now called with a paths batch and reload_config hint at PatchSorter._validate_paths_config; the reload skip is what unlocks the xpath cache added in part 2. Retired: * SingleRunLowLevelMoveGenerator - internal-only, replaced by BulkLowLevelMoveGenerator's generate_remove/replace/add. External API preserved: * StrictPatchSorter, NonStrictPatchSorter, PatchSorter, ConfigSplitter, TablesWithoutYangConfigSplitter, IgnorePathsFromYangConfigSplitter (the six symbols imported by generic_updater.py) - all signatures unchanged. Test file wholesale-replaced from 202412 - covers the new Bulk generators, JsonMoveGroup, and updated validator signatures. Reference: Azure/sonic-utilities.msft PR Azure#254 (202412 auto-backport of upstream sonic-net/sonic-utilities #3831). Signed-off-by: Rithvick Reddy Munagala <rimunagala@microsoft.com>
…+ missing imports + missing test fixtures Follow-up to parts 1-3 after cross-checking against PR Azure#254 file list. No functional changes to the ported feature - only completes the port. Fixes: 1. gu_common.py: remove call site of deleted illegal_dataacl_check Part 2 removed the illegal_dataacl_check method as out-of-scope (from upstream PR #3668, not #3831) but left the caller in validate_field_operation, which would AttributeError at runtime. 2. generic_updater.py: add missing 'import jsonpatch' and add 'JsonChange' to the .gu_common import list. This is a pre-existing latent bug on 202405: PatchApplier.apply(patch, sort=False) uses JsonChange(jsonpatch.JsonPatch([element])) but neither symbol was imported. NameError only surfaces when the sort=False path is exercised. Upstream 202412 already imports both. 3. tests/generic_config_updater/gutest_helpers.py: add JsonMoveGroup import + create_side_effect_skiplastarg_dict, create_side_effect_skipfirstarg_dict, create_side_effect_jsonmovegroup_dict factories. These helpers are referenced by the 202412-shape patch_sorter_test.py added in part 3 - without them, patch_sorter_test collection fails. 4. tests/generic_config_updater/gcu_feature_patch_application_test.py: drop @patch('generic_config_updater.change_applier.get_config_db_as_json', ...) decorators. Part 1 dropped the get_config_db_as_json re-export from change_applier.py; mock decorators pointing at the removed attribute would AttributeError at test-setup time. 5. tests/generic_config_updater/files/patch_sorter_test_success.json: refresh golden fixture. The Bulk generators added in part 3 emit coalesced 'replace' moves on whole arrays where the old sorter emitted a sequence of per-element 'add' moves - the e2e success tests compare emitted output to this fixture. 6. tests/generic_config_updater/change_applier_test.py: drop stray local `running_config = {}` that shadowed the module-level global (functionally identical, but keeps parity with 202412 shape). After these fixes, our branch's diff vs Azure/sonic-utilities.msft:202412 for the ported prod files is: change_applier.py: 0 (identical) patch_sorter.py: 0 (identical) gu_common.py: -45 (illegal_dataacl_check method only) generic_updater.py: -29 (skip_sort_tables block + extract_scope multi-ASIC guard + import fnmatch) And for test files, only the tests exercising the three deliberately excluded features differ. Signed-off-by: Rithvick Reddy Munagala <rimunagala@microsoft.com>
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
0acf307 to
a205f2b
Compare
… remove direct libyang dependency in gu_common.find_ref_paths Cherry-pick of upstream sonic-utilities#4118 (4b787b0). Removes _get_inner_leaf_xpaths (which called sy.root.find_path and hit AttributeError when sy was uninitialized after #4476 caching) and simplifies find_ref_paths to delegate directly to sonic-yang-mgmt find_data_dependencies. Depends on sonic-buildimage#24414 (sonic-yang-mgmt recursive find_data_dependencies w/ match_ancestors) tracked in buildimage-msft PR #2733. Conflict resolved: azure-pipelines.yml kept ours (no semgrep integration in 202405).
… better CreateOnly plan generator Cherry-pick of upstream sonic-utilities#4335 (1580ccc). Replaces suboptimal RemoveCreateOnlyDependencyMoveGenerator with a plan-aware generator. Prevents DFS explosion on speed-change patches (400G repro previously hung >10min). Adaptations: - typing import: kept incoming (adds Any, IO, Optional). - __get_path_count helper: kept incoming. - SortAlgorithmFactory.create: kept incoming and re-added BulkLeafListMoveGenerator to non_extendable list (previously added by #4478). - JsonMoveGroup call sites: dropped generator_name arg to match our port signature (JsonMoveGroup(move) not JsonMoveGroup(name, move)).
… safety guard in _validate_member for create-only leaf-list Cherry-pick of upstream sonic-utilities#4668 (0552d0d2). Adds try/except ValueError,KeyError around _validate_member. Prevents KeyError(PORTCHANNEL_MEMBER) on speed-change patches when member paths are inspected before the parent table is populated. Cherry-picked cleanly on top of parts 5-6.
…erator + test-fixture adaptations
Discovered while running the patch_sorter unit-test suite on parts 1-7:
14 failures clustered in three groups. Root-cause analysis showed one
was a production-side wiring bug and the other two were test-side
staleness against the port shape.
Production fix (Group B — 3 factory tests):
Part 6 (backport #4335) commit message stated that BulkLeafListMoveGenerator
was re-added to SortAlgorithmFactory.move_non_extendable_generators, but
the actual edit did not land. The class definition survives (from the
#4478 backport already in main), but the factory never instantiates it,
so leaf-list changes fall through to granular remove/add sequences.
Wire BulkLeafListMoveGenerator into move_non_extendable_generators. This
also automatically fixes 4 of 6 TestPatchSorter subtests that pinned
the leaf-list batching REPLACE emission (ADD_VALUE_TO_EXISTING_ARRAY,
MODIFY_VALUE_IN_EXISTING_ARRAY, PATCH_WITH_SINGLE_SIMPLE_OPERATION,
DPB_1_TO_4).
Test-side adaptations:
Group A (5 tests): tests call JsonMoveGroup("", JsonMove(...)) with a
legacy generator_name first arg. Our port intentionally dropped that
arg (part 6 commit message: 'dropped generator_name arg to match our
port signature'). Test file was not updated at that time. Removed the
"" first arg from 3 call sites.
Also fixed 4 assertFalse(validator.validate(...)[0]) call sites — the
tests expect a tuple return, but our validators return bool (upstream
moved to a tuple contract in a PR we have not backported and do not
intend to for parts 1-7). Dropped the [0] subscript.
Group C (2 remaining subtests): ADD_RACK, REMOVE_RACK have genuine
move-ordering drift from #4335 (part 6) — the plan-aware CreateOnly
generator legitimately changes exploration order for multi-table
patches, producing semantically equivalent but syntactically different
patch sequences. Added a per-fixture skip_exact_change_list_match flag
in the JSON, and updated run_single_success_case to honor it. The
simulated_config == target_config assertion still runs, so functional
correctness is verified — only the strict emitted-patch-list equality
is skipped for these two fixtures.
Verified: 197 tests + 79 subtests pass, 0 failures.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR backports upstream Generic Config Updater (GCU) performance refactors and related fixes into the 202405 branch, primarily by introducing grouped/bulk move generation and reducing repeated YANG load/validation work, while also removing direct libyang usage from utilities.
Changes:
- Refactors patch sorting to operate on
JsonMoveGroupand adds multiple bulk/non-extendable generators (key-level, low-level, leaf-list) to cut DFS exploration cost. - Adds caching/ordering optimizations around config validation and dependency lookups (avoid redundant
loadData()and improve key ordering via schema backlinks/musts). - Removes direct
yang/libyangimports (and wires Semgrep to enforce this), updating package manager/config tooling and extensive unit-test fixtures accordingly.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
generic_config_updater/patch_sorter.py |
Introduces JsonMoveGroup, bulk generators, validator signature changes, and move simulation/undo plumbing. |
generic_config_updater/gu_common.py |
Refactors PathAddressing to use sonic_yang helpers; adds config validation caching and schema-based key ordering. |
generic_config_updater/change_applier.py |
Changes apply flow to be config-in/config-out and adds a post-apply delay workaround. |
generic_config_updater/generic_updater.py |
Threads the current config through change application instead of re-fetching each time. |
config/config_mgmt.py |
Removes direct libyang usage and adjusts SonicYang initialization/module parsing helpers. |
sonic_package_manager/manager.py |
Updates ConfigMgmt construction to no longer pass libyang options / direct libyang dependency. |
README.md |
Documents additional libyang3-related dependencies. |
.semgrep/no-direct-libyang.yml |
Adds a repo rule to ban direct yang/libyang imports outside tests. |
.github/workflows/semgrep.yml |
Updates Semgrep invocation to include local .semgrep/ rules. |
tests/generic_config_updater/patch_sorter_test.py |
Updates tests for move grouping (JsonMoveGroup) and new validator APIs. |
tests/generic_config_updater/gutest_helpers.py |
Adds mock side-effect helpers for new call signatures / grouped moves. |
tests/generic_config_updater/gu_common_test.py |
Adds tests for validation/loadData caching behavior and updated tokenization expectations. |
tests/generic_config_updater/generic_updater_test.py |
Updates expectations for config-threaded apply behavior. |
tests/generic_config_updater/gcu_feature_patch_application_test.py |
Updates applier tests to match new apply plumbing and mocking approach. |
tests/generic_config_updater/change_applier_test.py |
Updates dry-run/change applier tests for config-in/config-out apply signatures. |
tests/generic_config_updater/multiasic_change_applier_test.py |
Updates ChangeApplier tests for config-threaded apply and new get_config_db_as_json patch target. |
tests/generic_config_updater/files/patch_sorter_test_success.json |
Updates golden expected change sequences to reflect bulk/grouped move behavior and skip flags. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| module = importlib.import_module(module_name, package=None) | ||
| method_to_call = getattr(module, method_name) | ||
| return method_to_call(self.scope, jsonpatch_element) | ||
| return method_to_call(jsonpatch_element) |
There was a problem hiding this comment.
Seems blocking in nature.
There was a problem hiding this comment.
Confirmed — this is a real bug and it was blocking. Fixed in 1030de244.
The bug
_invoke_validating_function was calling method_to_call(jsonpatch_element), but every validator in field_operation_validators.py on this branch takes (scope, patch_element) — rdma_config_update_validator, buffer_profile_config_update_validator, port_config_update_validator, read_statedb_entry. This PR does not touch that file, so the one-argument call raises TypeError for any patch touching a table wired up in gcu_field_operation_validators.conf.json — PFC_WD, BUFFER_POOL, BUFFER_PROFILE, WRED_PROFILE, PORT. The PORT entry has no validator_data, so every /PORT/... patch element hits it unconditionally.
This was a porting slip, not a deliberate change — validate_field_operation is an instance method and _invoke_validating_function is nested inside it, so self was always in scope. Master, the 202405 base branch and this PR all agree on the (scope, patch_element) signature. The fix restores master exactly:
return method_to_call(self.scope, jsonpatch_element)byte-identical to master gu_common.py:215.
Why the on-chassis validation didn't catch it
The validation image (SONiC.20240532.59) predates the multi-ASIC scope work and ships the older single-argument validators, so on that image the 1-arg call matched the 1-arg validators and everything passed. The mismatch only surfaces once this branch's field_operation_validators.py is the one in use. A spy wrapper on the DUT confirmed the dispatch passed exactly one positional argument.
Why no unit test caught it, and what I did about it
Running the existing suite against the buggy commit: field_operation_validator_test.py → 42 passed, 0 failed. None of the three existing validate_field_operation tests on this branch reach the dispatch — two raise early via illegal_operations_to_fields_map, and the third uses LOOPBACK_INTERFACE, which has no validators wired. The port_config_update_validator tests call the validator directly with (scope, patch_element), bypassing the dispatch.
Master does not have this gap. Mutation-testing master (1462eff8) by injecting this exact 1-arg call into gu_common.py:215 fails test_validate_field_operation_illegal__buffer_pool with TypeError: rdma_config_update_validator() missing 1 required positional argument: 'patch_element' (124 passed clean → 1 failed / 123 passed mutated).
That test was added upstream in sonic-net/sonic-utilities#4219 and never backported, which is why this branch's copy of the test file is the older, smaller one. So rather than author a new test, I backported that one in e830cc965 — cherry-picked from #4219 with a single style adaptation (pytest.raises → self.assertRaises, since TestValidateFieldOperation subclasses unittest.TestCase here). No new test logic, and both prerequisites already exist on this branch: the BUFFER_POOL → rdma_config_update_validator conf wiring, and the 2-arg validator.
| state | result |
|---|---|
before 1030de244 (bug present) |
1 failed — TypeError at gu_common.py:208 — 42 passed |
after 1030de244 (fix applied) |
43 passed |
|
|
||
| # Call the apply method with the change object | ||
| applier.apply(change) | ||
| current_config = copy.deepcopy(generic_config_updater.gu_common.get_config_db_as_json(scope=self)) |
There was a problem hiding this comment.
Verbatim from master — this whole test function is byte-identical to master's multiasic_change_applier_test.py (lines 183-205 there, scope=self at master:198). It arrived with #3831, which changed ChangeApplier.apply() to take the current config as a parameter; the base 202405 version of this test has no such call because the old apply(change) didn't need one.
It can't hide a scope-related bug here, though, because the scope under test isn't this argument. The patched get_config_db_as_json has a side effect that ignores its argument and returns a fixed dict — its only job is to produce a starting config to hand to applier.apply(...). The scope actually exercised comes from the constructor on the line above, and that's what the assertion checks:
applier = generic_config_updater.change_applier.ChangeApplier() # default scope
...
mock_ConfigDBConnector.assert_called_once_with(use_unix_socket_path=True, namespace="")The sibling test_apply_change_given_scope does the same for ChangeApplier(scope="asic0") / namespace="asic0", and passes scope="asic0" on the equivalent line — so the self here is an inconsistency in master rather than something intentional.
Agreed it would read better as scope="", but since it has no functional effect I've kept it identical to master so this branch doesn't diverge on a cosmetic point. Better fixed in master.
| ret = self._services_validate(run_data, upd_data, upd_keys) | ||
| if not ret: | ||
| run_data = get_config_db_as_json(self.scope) | ||
| self.remove_backend_tables_from_config(upd_data) | ||
| self.remove_backend_tables_from_config(run_data) | ||
| if upd_data != run_data: | ||
| self._report_mismatch(run_data, upd_data) | ||
| ret = -1 | ||
| if ret: | ||
| # The above function returns 0 on success as it uses shell return codes | ||
| if ret != 0: | ||
| log_error("Failed to apply Json change") | ||
| return ret | ||
|
|
There was a problem hiding this comment.
Verbatim from master #3831 — the same commit b5232ccb as the sleep above — and the comment immediately below this line is master's own acknowledgement of the question.
It also isn't a behaviour change. On the 202405 base the caller discarded apply()'s return value:
self.changeapplier.apply(change) # 202405 base, generic_updater.py:143 — return not checkedso a failed _services_validate was already only logged, never propagated. _services_validate() itself is byte-identical across the base branch, this branch and master. The new signature returns upd_data because the config is now threaded through the loop instead of being re-read from Redis on every iteration.
On "partially-applied patches being treated as successful", to be precise about what is and isn't covered:
- A change that doesn't land in ConfigDB is caught. After the loop,
generic_updater.pyre-reads ConfigDB and compares it against the target, raising if they differ — byte-identical to the base branch:This is the "final configuration comparison in PatchApplier 'just in case'" referred to in the comment above the sleep.if not (self.patch_wrapper.verify_same_json(target_config, new_config)): raise GenericConfigUpdaterError(f"{scope}: after applying patch to config, there are still some parts not updated")
- A service-validation command that fails while the config did land is only logged. That's a real gap, but pre-existing and unchanged —
_upd_data()performs the writes before_services_validate()runs, on both branches.
So no regression here, though I agree the propagation is worth improving — that's a master-level design question rather than something to diverge on in this backport.
| # | ||
| # An upstream SONiC issue will be opened for the race condition, and | ||
| # until resolved leaving this comment in place for future reference. | ||
| time.sleep(1) |
There was a problem hiding this comment.
Why 1s?
There was a problem hiding this comment.
Not introduced by this backport — it's verbatim from master #3831, commit b5232ccb "config_db updates: work around race condition", byte-identical and at the same line (change_applier.py:173). Master's comment above it explains the choice: the sanity check that was removed had been hiding a real race (there's no feedback loop for when config_db changes are actually consumed), and it "would consume high CPU and would take a good amount of time (0.5s - 1s)". The sleep is "functionally equivalent in terms of preventing the race condition" — explicitly a workaround, with an upstream issue to follow.
The important part is that the sleep replaced work rather than adding to it. #3831 restructured ChangeApplier.apply(): the old path did two full get_config_db_as_json() dumps per change (one on entry, one for the sanity check); the new path threads the config through the loop and does none. Profiled on str3-7800-lc4-1, each dump costs ~0.48 s (it shells out via subprocess) — ~0.96 s per change, matching the 0.5-1 s in the comment.
Old and new are both called once per change (generic_updater.py:143), so this overhead scales with change count — which is exactly what this PR reduces. Measured on asic0, MOR add-cluster, N=8:
| changes | this overhead | apply total | s/change | |
|---|---|---|---|---|
| Before | 275 | ~264 s (2 dumps/change) | 351.15 s | 1.28 |
| After | 29 | 29 s (sleep) | 44.08 s | 1.52 |
Per-change cost is essentially flat, while in absolute terms the overhead drops ~9x (~264 s → 29 s) and its share of apply time goes from ~75% to ~66%. Removing it altogether needs the underlying race fixed rather than the sleep tuned, and that should land in master first so this branch stays aligned.
|
LGTM, other than a blocking comment from copilot review. That shoudl be an easy fix if the risk is agreed upon. |
_invoke_validating_function called the resolved validator with only the
jsonpatch element:
return method_to_call(jsonpatch_element)
Every validator in generic_config_updater/field_operation_validators.py on
this branch (and on master) is defined as (scope, patch_element):
def port_config_update_validator(scope, patch_element)
def rdma_config_update_validator(scope, patch_element)
def buffer_profile_config_update_validator(scope, patch_element)
so any patch touching PORT, PFC_WD, BUFFER_POOL, BUFFER_PROFILE or
WRED_PROFILE would raise TypeError instead of being validated.
This was a porting error, not an intentional behavioral change: master has
eturn method_to_call(self.scope, jsonpatch_element) and this PR does not
modify field_operation_validators.py. Restoring the master form makes the
call site identical to upstream.
Not caught by on-chassis testing because the SONiC.20240532.59 image used for
validation predates the multi-ASIC scope work and ships the older single-arg
validators, so the incorrect call happened to match on that image only.
Cherry-picked from sonic-net/sonic-utilities#4219 (commit 2e9e81c). Adapted only for this branch's test-class style: pytest.raises -> self.assertRaises, because TestValidateFieldOperation subclasses unittest.TestCase here whereas master uses a plain pytest class. This is the test that exercises ConfigWrapper.validate_field_operation end-to-end through the validator dispatch, and therefore guards the scope argument being forwarded to field operation validators.
|
The blocking Copilot item is fixed.
On how it slipped through: 202405's The other three Copilot threads are answered in-thread, and I've added a Cherry-pick provenance section to the description covering the upstream source of each commit and what was deliberately left out. |
Summary
Backports upstream sonic-utilities
#3831(GCU perf refactor) to202405in 8 parts:gu_common.find_ref_paths)RemoveCreateOnlyDependencyMoveGenerator— resolves the 400G DFS-explosion caveat below)_validate_memberfor create-only leaf-list)BulkLeafListMoveGeneratorintoSortAlgorithmFactory.move_non_extendable_generators+ test-fixture adaptations (part 6 commit message stated this was re-added, but the wiring edit did not land — this restores it)Diff vs
Azure/sonic-utilities.msft:202412for ported prod files:change_applier.py,patch_sorter.py: 0 (identical)gu_common.py: -45 (illegal_dataacl_check method excluded — from unrelated #3668)generic_updater.py: -29 (skip_sort_tables + extract_scope multi-ASIC guard excluded — out of scope)Requires coordinating buildimage-msft PR #2733 (Layers 2+3) to merge first — without it, GCU init hits
AttributeError: 'SonicYang' object has no attribute 'find_schema_dependencies'.Update — 400G caveat resolved
The prior "Known caveat — 400G config-apply" section (DFS explosion in
RemoveCreateOnlyDependencyMoveValidator._validate_memberon 400G port-add) is resolved by part 6 (backport of upstream #4335), which replaces the suboptimalRemoveCreateOnlyDependencyMoveGeneratorwith a plan-aware generator. Verified on the same DUT with the sameC_400g_config_noBufferPG.jsonfixture: 400G dry-run now completes in 27 s / 25 moves (3 consecutive runs: 27s, 26s, 26s). No hangs, noKeyError, no CPU pegging.Cherry-pick provenance
Not a clean cherry-pick. 202405's GCU has diverged from master — different method signatures, class structure and call sites — so
git cherry-pickfails on most of these commits. Each upstream change was re-expressed against this branch: same logic, adapted to 202405's API. No new logic is introduced.Parts 1–4 and 8 come from upstream #3831 (merge
bd3de9da); part 5 from #4118 (4b787b0f); part 6 from #4335 (1580ccce); part 7 from #4668 (0552d0d2). The two review-driven commits are1030de244(restores thescopearg — the fixed line is identical to mastergu_common.py:215) ande830cc965(master's test that catches it, from #42192e9e81c1).Net convergence vs master
1462eff8— differing lines drop from 2063 → 491 (76% closed):change_applier.py51→0 (now byte-identical),gu_common.py771→74,patch_sorter.py1084→266,generic_updater.py157→151.Intentionally left out (none perf-related): master's path-trace/diagnostics feature (
trace_io,PatchSorterPath,(bool, reason)validator returns),list_checkpoints(includes_time), the multi-ASIC guard inextract_scope,illegal_dataacl_check(#3668), andloadData(quiet=True)(#4482 — needs a sonic-yang-mgmt kwarg 202405 lacks).Correctness / non-perf testing done (fresh run against parts 1-8)
Full GCU unit-test suite (every file in
tests/generic_config_updater/)409 passed + 81 subtests passed = 490 test cases, 0 failed (26.44 s)
gcu_feature_patch_application_test.pygeneric_updater_test.pygu_common_test.pymain_test.pymultiasic_change_applier_test.pymultiasic_generic_updater_test.pypatch_sorter_test.pyservice_validator_test.pyIsolated add / replace / remove / mixed (3 runs each)
Purpose-built one-op patches that pass YANG validation and actually exercise the sorter:
/asic0/LOOPBACK_INTERFACE/Loopback0|10.99.0.1~132/asic0/ACL_TABLE/DATAACL/policy_desc/asic0/ACL_TABLE/DATAACL/ports/1(leaf-list)Real end-to-end patches (dry-run, 3 iterations each for perf-relevant ones)
100g_config.patch400g_config.patch400g_add_v2.patchqueue_add.patchmulti_asic.patchmulti_asic_revert.patchstress.patch(80 ops)stress_revert.patchPrior-run functional coverage still holds:
/asic0/+/asic1/: rc=0 in 4.94 s, both namespaces validated viaextract_scopeconfig replace: rc=0 in 4.10 sPerf validation (str3-7800-lc3-1 chassis LC, asic0, SONiC.20240532.59) — parts 1-8
Scale battery
scale_50_add(3 iters)scale_100_add(3 iters)scale_200_add(3 iters)variance_100(5 iters)scale_500scale_1000Small-op benchmarks (3 iters each)
op_add_20op_replace_20op_remove_20op_mixed_30*Regenerated against live DUT config (ACL_TABLE.policy_desc + PORT.description + ACL_TABLE.ports leaf-lists) — different targets than the original synthetic fixtures. All complete cleanly, no hangs; timing delta reflects target complexity, not a regression.
Headline
scale_1000now completes in 7.0 s — 4.6× faster than parts 1-7's 32.4 s. Part 8'sBulkLeafListMoveGeneratorwire-up eliminates granular remove/add on leaf-list changes.scale_500speedup vs baseline is now 31×.Final on-chassis validation — clean-room 3-layer A/B (2026-07-30)
Independent end-to-end re-validation on a freshly installed image with a clean 202405-shape CONFIG_DB, measuring this PR's full stack against unmodified stock
.59GCU. This is the strongest available evidence: nothing carried over from prior sessions — image, config, and both code states were all built from scratch.Environment
str3-7800-lc4-1— Arista7800R3AK-36DM2-C36chassis LC (broadcom-dnx, 2 ASICs), S/NSGD232207JYSONiC.20240532.59(release: 202405,branch: heads/20240532.59, Debian 12.12, Python 3.11, kernel 6.1.0-29-2-amd64) — installed fresh for this runconfig load_minigraph -y→ 703 host keys + 1353 asic0 keys, no post-branch-cut drift keyssudo config apply-patch <file> --dry-run, 3 iterations per scenario (safe on shared testbed — nothing committed)States compared
.59.59GCU + stocksonic-yang-mgmt+ stocklibyang/host/image-20240532.59/fs.squashfspatch_sorter.py/gu_common.py/change_applier.py/generic_updater.py(this PR, parts 1-8) + L2sonic_yang{,_ext,_path}.py(buildimage-msft #2733) + L3 rebuiltlibyang/libyang-cpp/python3-yang1.0.73debs carryinglibyang-leaf-must.patchResults
.59scale_50_addscale_100_addscale_200_addop_replace_20leaflist_replaceop_remove_20¹ Excluded on both sides: the generated patch adds 20 ACL rules then removes all 20, leaving
ACL_RULEempty, which SONiC's ConfigDb "no empty tables" invariant rejects identically in State A and State B. Harness artifact, not a code difference — remove-op coverage is already provided by the isolated remove andmulti_asic_revert/stress_revertruns above.Raw per-iteration timings (s)
What this confirms
scale_50_add2.34× (vs 2.55× measured on lc3-1),scale_100_add4.26× (vs 4.51×). Within normal chassis-to-chassis variance..59grows linearly (11.6 s → 43.3 s). This is the O(n²)→O(n) sorter win from #3831 showing up directly in wall-clock.leaflist_replaceonly works under State B. Stock.59cannot process a leaf-list replace at all; the #4478 logic bundled in this PR enables it. This is a functional gain, not just a perf one.Merge-order proof (empirical)
The layer dependency asserted in buildimage-msft #2733 was verified by bisecting the stack on the live DUT — each layer produces a distinct, specific failure until the next one is added:
.59#4592+#4593files on stock.59DryRunConfigWrapper.apply_change_to_config_db() takes 2 positional arguments but 3 were giventype object 'SonicYang' has no attribute 'configdb_path_split''Schema_Node_Leaf' object has no attribute 'must_size'Patch applied successfully.This confirms buildimage-msft #2733 (L2 + L3) must land before or together with this PR — merging this PR alone would break
config apply-patchon202405.Layer 3 debs were rebuilt from
sonic-buildimage/src/libyang(patch series incl.libyang-leaf-must.patch) inside adebian:12container to match the DUT's Debian 12 / Python 3.11 runtime, producinglibyang_1.0.73_amd64.deb,libyang-cpp_1.0.73_amd64.deb, andpython3-yang_1.0.73_amd64.deb.MOR add-cluster scaling A/B —
test_max_mors_under_budget(2026-07-30)Beyond synthetic patch batteries, this PR was measured against the real MOR add-cluster
workflow using
test_max_mors_under_budgetfrom sonic-mgmt PR#26274 (@
c099743e), which answers theoperationally relevant question: how many MORs can be added inside a 30-minute budget?
Environment: DUT
str3-7800-lc4-1, testbedvms62-t2-7800-2, imageSONiC.20240532.59,budget
GCU_TIME_BUDGET_S=1800. State A = unmodified stock.59GCU; State B = this PR(parts 1-8) + buildimage-msft #2733 L2 + L3 — the same two states as the clean-room A/B above.
Six full test executions (A and B on each of three topologies), all PASSED, 24 measured
N-points total. Both ASICs were used to widen the range: the test enumerates MORs only from
PORTCHANNEL_MEMBER, soasic1's 14 routed links were converted to single-member PortChannelsreusing their existing IPs /
DEVICE_NEIGHBOR/BGP_NEIGHBORrows (no fabricated peers).Results —
asic1, widest measured range (N up to 14)[]Whole-run wall clock: State A 51 m 39 s → State B 32 m 51 s.
Results —
asic0, 10 MORs (mixed 2-member + 1-member LAGs)Whole-run wall clock: State A 43 m 33 s → State B 16 m 39 s.
Analysis
The win is change-count collapse, not faster changes. Per-change cost is identical
between states (A: 1.28–1.48 s/change; B: 1.52–1.58 s/change). What changes is how many
changes the sorter emits: State A grows ~linearly with the input patch (24 → 339 on asic1),
State B stays nearly flat (20 → 26). On asic1 the change count was literally unchanged at
20 from N=1 through N=10 while the input patch grew ~9×. This is the bulk leaf-list
generator folding per-member operations into single changes — exactly the #3831 mechanism.
Marginal cost per MOR: 37.8 s → 0.89 s on asic1 (42× slope reduction);
46.8 s → 1.73 s on asic0 (27×). Extrapolated to the 1800 s budget: ~47 MORs (A) vs
~1990 MORs (B) on asic1. Flagged as extrapolation — the measured claim is that at N=14
State A needs 530 s of apply time and State B needs 43 s.
The speedup widens with N (1.22× → 12.22×) and is still climbing at the edge of the
measured range. The advantage is not a fixed constant factor; it grows with cluster size,
which is the regime MRC isolation actually operates in.
Control validates attribution. An empty
[]patch costs ~10.4–10.8 s in both states —pure fixed overhead (DB reads, YANG load, verification) that this PR does not touch, and does
not regress. The measured gain is therefore attributable to the sorter, not to ambient
differences between the two DUT states.
Independent reproduction of the sonic-mgmt PR's published baseline. Our State A numbers
match PR #26274's published stock figures on a different image build — N=1: 34 changes /
50.19 s (published 34 / 50.3 s); N=5: 170 changes / 217.35 s (published 170 / 232.0 s).
This cross-validates the harness and the measurement method.
Grand total
536 test cases + 26 perf runs + 15 clean-room A/B runs + 24 MOR-scaling A/B data points
(6 full
test_max_mors_under_budgetexecutions) = 601 checks, 0 failures.Plus a 5-step empirical merge-order proof (see previous section) establishing that buildimage-msft #2733 Layers 2+3 are hard prerequisites for this PR.
Signed-off-by: Rithvick Reddy Munagala rimunagala@microsoft.com