Skip to content

Strategic merge patches cost O(n²) each: resWrangler.ApplySmPatch re-appends every resource with a linear duplicate-ID scan #6300

Description

@Itsindigo

This issue was drafted in part with the assistance of generative AI; the reproduction and measurements were run and checked locally.

What happened?

Build time grows quadratically with resource count for every strategic merge patch, even when the patch targets a single named resource.

resWrangler.ApplySmPatch (api/resmap/reswrangler.go, unchanged between v5.8.1 and master) applies the patch, then rebuilds the whole map:

m.Clear()
return m.appendAll(list)

appendAll calls Append per resource, and Append checks for a duplicate ID with GetMatchingResourcesByCurrentId(id.Equals), a linear scan over the resources already appended that calls CurId() on each one. One patch therefore does ~n²/2 CurId() + ResId.Equals calls, and a build with p SMP patches does ~p·n²/2.

Reproduction (kustomize v5.8.1, darwin/arm64; one target per patch, n ConfigMaps, 80 SMP patches):

"""Generate a kustomization with N ConfigMaps and P strategic merge patches.

Usage: python3 gen.py N P MODE

MODE:
  distinct  each patch targets a different ConfigMap (cm-0, cm-1, ...)
  same      every patch targets cm-0
  merged    one patch on cm-0 carrying all P annotations
"""

import sys
from pathlib import Path


def config_map(name: str) -> str:
    return f"""\
apiVersion: v1
kind: ConfigMap
metadata:
  name: {name}
data:
  a: x
---
"""


def patch(target: str, annotations: dict[str, str]) -> str:
    lines = "".join(f"          {key}: {value}\n" for key, value in annotations.items())
    return f"""\
  - target: {{kind: ConfigMap, name: {target}}}
    patch: |-
      apiVersion: v1
      kind: ConfigMap
      metadata:
        name: {target}
        annotations:
{lines}"""


def patches(resource_count: int, patch_count: int, mode: str) -> str:
    if mode == "merged":
        return patch("cm-0", {f"k{j}": f"v{j}" for j in range(patch_count)})
    rendered = []
    for j in range(patch_count):
        target = "cm-0" if mode == "same" else f"cm-{j % resource_count}"
        rendered.append(patch(target, {f"k{j}": f"v{j}"}))
    return "".join(rendered)


def main() -> None:
    resource_count, patch_count, mode = int(sys.argv[1]), int(sys.argv[2]), sys.argv[3]
    out = Path("synthetic")
    out.mkdir(exist_ok=True)

    resources = "".join(config_map(f"cm-{i}") for i in range(resource_count))
    (out / "resources.yaml").write_text(resources)

    kustomization = """\
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- resources.yaml
patches:
"""
    (out / "kustomization.yaml").write_text(
        kustomization + patches(resource_count, patch_count, mode)
    )


if __name__ == "__main__":
    main()
for n in 100 400 800; do python3 gen.py $n 80 distinct; /usr/bin/time -p kustomize build synthetic >/dev/null; done
python3 gen.py 400 80 same;   /usr/bin/time -p kustomize build synthetic >/dev/null
python3 gen.py 400 80 merged; /usr/bin/time -p kustomize build synthetic >/dev/null
resources 80 SMP patches wall
100 one per resource 0.08s
400 one per resource 0.83s
800 one per resource 3.31s
400 all 80 on cm-0 0.86s
400 one merged patch on cm-0 (same output, cmp identical) 0.09s

Doubling resources from 400 to 800 quadruples the time, and 80 patches on one object cost the same as 80 patches spread across objects, so the cost is per patch × n², not the merge work itself.

Real-world impact. One of our overlays (745 base resources, 51 components, ~1,300 patches in total) takes ~42s to build, CPU-bound on one core. Without components it builds in 11s; without the overlay's own SMP patches, in 31s. A CPU profile of krusty.Kustomizer.Run (v0.21.1 API, output byte-identical to kustomize build) shows, cumulative, out of 41.8s sampled:

PatchTransformer.transformStrategicMerge   27.42s
resWrangler.ApplySmPatch                   27.05s
resWrangler.appendAll / Append             30.4s
resWrangler.GetMatchingResourcesByCurrentId 30.3s
Resource.CurId                             26.48s

Flat time is dominated by IndexByteString, memequal and map hashing under CurId()/ResId.Equals. Converting that overlay's 112 SMP patches to equivalent JSON6902 patches kept the output byte-identical and cut its build from ~42s to ~32s (a second overlay with 94 such patches went from ~35s to ~30s). We're applying that workaround on our side.

What did you expect to happen?

Applying one SMP patch should cost roughly O(n) for selection plus the merge itself, not O(n²).

Possible fix. ApplySmPatch only needs the rebuild to drop resources that the patch emptied ($patch: delete) and to reject ID collisions a patch may introduce (for example, a renamed resource). A single map pass over list, keyed by the ID with its namespace normalised the way ResId.IsNsEquals treats "" and default, detects collisions in O(n) before assigning m.rList = list, without Clear() + appendAll(). Caching CurId() per resource during the pass would also avoid re-parsing each resource's ID. This would complement the regressions discussed in #5422. Happy to send a PR with a failing-benchmark commit followed by the fix, per CONTRIBUTING.md, if a maintainer agrees with the approach.

Kustomize version: v5.8.1 (also via kubectl kustomize, kubectl v1.37.0). Code path unchanged on master.

Operating system: macOS (darwin/arm64); same behaviour on Linux CI runners.

Activity

  1. added
    needs-kindIndicates a PR lacks a `kind/foo` label and requires one.
    needs-triageIndicates an issue or PR lacks a `triage/foo` label and requires one.
    on Sep 29, 2026
  2. kubernetes-prow commented on Sep 29, 2026

    @kubernetes-prow
    Contributor

    This issue is currently awaiting triage.

    SIG CLI takes a lead on issue triage for this repo, but any Kubernetes member can accept issues by applying the triage/accepted label.

    The triage/accepted label can be added by org members by writing /triage accepted in a comment.

    Details

    Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository.

  3. Itsindigo commented on Sep 29, 2026

    @Itsindigo
    Author

    /kind bug

  4. added
    kind/bugCategorizes issue or PR as related to a bug.
    and removed
    needs-kindIndicates a PR lacks a `kind/foo` label and requires one.
    on Sep 29, 2026
  5. Itsindigo commented on Sep 29, 2026

    @Itsindigo
    Author

    I've got a fix ready locally and wanted to share the numbers before opening a PR, in case you'd prefer a different approach.

    The change is confined to resWrangler.ApplySmPatch. Instead of m.Clear() + m.appendAll(list), it checks for ID collisions in one pass with a map keyed on the same fields ResId.Equals compares (GVK, name, effective namespace), then assigns m.rList = list. The collision error text is unchanged, resource order is preserved, and $patch: delete behaves as before.

    BenchmarkApplySmPatch (one SMP patch, n ConfigMaps, medians of 6 runs):

    resources before after
    100 553 µs 90 µs
    400 9.02 ms 173 µs
    800 36.5 ms 287 µs
    1,600 145 ms 532 µs

    Allocations rise slightly because of the map (198 → 344 KiB/op at 1,600).

    On our real overlays, built from master with and without the change, the output is byte-identical and wall time drops from ~40s to ~11s for both of the overlays I measured. go test ./... in api/ shows the same results with and without the fix.

    If this approach looks right, I'll open a PR with the tests/benchmark in one commit and the fix in a second, per CONTRIBUTING.md.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    kind/bugCategorizes issue or PR as related to a bug.needs-triageIndicates an issue or PR lacks a `triage/foo` label and requires one.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions