Skip to content

fix: reduce memory consumption by eliminating redundant string copies#965

Merged
yxxhero merged 5 commits intomasterfrom
fix/memory-optimization-915
Mar 29, 2026
Merged

fix: reduce memory consumption by eliminating redundant string copies#965
yxxhero merged 5 commits intomasterfrom
fix/memory-optimization-915

Conversation

@yxxhero
Copy link
Copy Markdown
Collaborator

@yxxhero yxxhero commented Mar 28, 2026

  • Nil out raw byte slices after parsing in cmd/upgrade.go, cmd/release.go, cmd/rollback.go, cmd/revision.go
  • Fix unkeyed Options struct literals in diff/diff_test.go
  • Skip length-ratio filter for Secrets in diff/diff.go
  • Fix CI: add //nolint:ineffassign comments on nil assignments (GC-hint pattern suppressed from linter)

💬 Send tasks to Copilot coding agent from Slack and Teams to turn conversations into code. Copilot posts an update in your thread when it's finished.

yxxhero added 2 commits March 28, 2026 18:28
Changes manifest.Parse and parseContent to accept []byte instead of
string, avoiding a full copy of the entire manifest when converting from
the raw helm output bytes. Raw byte slices are released after parsing
to allow GC reclamation before diff computation begins.

Also adds a content length ratio check in rename detection to skip
obviously different resources before computing expensive full diffs.

Closes #915

Signed-off-by: yxxhero <[email protected]>
The releaseManifest and installManifest nil assignments were flagged
by ineffassign since the variables are not read afterward. The main
memory optimization comes from the []byte signature change.

Signed-off-by: yxxhero <[email protected]>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR targets reduced peak memory usage in Helm Diff by avoiding unnecessary manifest string copies and by adding a cheap pre-filter in rename detection to skip expensive diffs for obviously dissimilar resources.

Changes:

  • Update manifest.Parse() / parseContent() to accept []byte and adjust call sites/tests accordingly.
  • Add a content-length ratio gate in contentSearch() to reduce rename-detection diff workload.
  • Update command flows (upgrade, release, revision, rollback) to pass raw manifest bytes into parsing.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
manifest/parse.go Switch parsing pipeline to accept []byte and trim/tokenize using byte-based scanning.
manifest/parse_test.go Update tests to call Parse with []byte input.
diff/diff.go Add length-ratio heuristic to skip expensive diffs during rename detection.
diff/diff_test.go Update tests to pass []byte into manifest.Parse.
cmd/upgrade.go Update upgrade diff flow to pass raw manifest bytes to parsing.
cmd/rollback.go Update rollback diff flow to pass raw manifest bytes to parsing.
cmd/revision.go Update revision diff flow to pass raw manifest bytes to parsing.
cmd/release.go Update release diff flow to pass raw manifest bytes to parsing.

Comment on lines 102 to 106
diff.Manifests(
manifest.Parse(string(revisionResponse), namespace, d.normalizeManifests, excludes...),
manifest.Parse(string(releaseResponse), namespace, d.normalizeManifests, excludes...),
manifest.Parse(revisionResponse, namespace, d.normalizeManifests, excludes...),
manifest.Parse(releaseResponse, namespace, d.normalizeManifests, excludes...),
&d.Options,
os.Stdout)
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this code path, the raw releaseResponse / revisionResponse manifest byte slices remain referenced while diff.Manifests runs. If the goal is to reduce peak memory, consider parsing into locals and then setting these byte slices to nil before starting the diff.

Copilot uses AI. Check for mistakes.
Comment on lines 111 to 115
if releaseChart1 == releaseChart2 {
seenAnyChanges := diff.Releases(
manifest.Parse(string(releaseResponse1), namespace1, d.normalizeManifests, excludes...),
manifest.Parse(string(releaseResponse2), namespace2, d.normalizeManifests, excludes...),
manifest.Parse(releaseResponse1, namespace1, d.normalizeManifests, excludes...),
manifest.Parse(releaseResponse2, namespace2, d.normalizeManifests, excludes...),
&d.Options,
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The raw releaseResponse1 / releaseResponse2 byte slices stay live for the duration of diff.Releases. If you want the earlier GC reclamation described in the PR, parse into locals first and then set the raw byte slices to nil before running the diff.

Copilot uses AI. Check for mistakes.
diff/diff.go Outdated
Comment on lines +206 to +208
ratio := float32(oldLen) / float32(newLen)
if ratio < 0.1 || ratio > 10 {
continue
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new length-ratio filter uses hard-coded thresholds (0.1 and 10) without local documentation or named constants. Consider extracting these into named constants (and/or briefly documenting the rationale) to make the heuristic easier to tune and reason about.

Copilot uses AI. Check for mistakes.
diff/diff.go Outdated
Comment on lines +206 to +208
ratio := float32(oldLen) / float32(newLen)
if ratio < 0.1 || ratio > 10 {
continue
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This newly introduced length-ratio heuristic affects rename detection, but there are no unit tests covering it. Adding tests around the threshold behavior (e.g., ratios just inside/outside the bounds) would help prevent regressions while optimizing --find-renames.

Copilot generated this review using guidance from repository custom instructions.
Comment on lines +72 to 74
func Parse(manifest []byte, defaultNamespace string, normalizeManifests bool, excludedHooks ...string) map[string]*MappingResult {
scanner := bufio.NewScanner(bytes.NewReader(append([]byte("\n"), manifest...)))
scanner.Split(scanYamlSpecs)
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parse still makes a full copy of the entire manifest via append([]byte("\n"), manifest...). This undermines the goal of reducing peak memory (the raw Helm output is duplicated once here). Consider avoiding the copy by using an io.MultiReader prefix, or by adjusting scanYamlSpecs to also recognize a separator at the beginning of the input (e.g., ---\n) so no leading newline buffer is needed.

Copilot uses AI. Check for mistakes.
Comment on lines 329 to 333
if d.includeTests {
currentSpecs = manifest.Parse(string(releaseManifest), d.namespace, d.normalizeManifests)
currentSpecs = manifest.Parse(releaseManifest, d.namespace, d.normalizeManifests)
} else {
currentSpecs = manifest.Parse(string(releaseManifest), d.namespace, d.normalizeManifests, manifest.Helm3TestHook, manifest.Helm2TestSuccessHook)
currentSpecs = manifest.Parse(releaseManifest, d.namespace, d.normalizeManifests, manifest.Helm3TestHook, manifest.Helm2TestSuccessHook)
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description mentions setting releaseManifest to nil after parsing so the large raw buffer can be reclaimed before diff computation. Here releaseManifest stays referenced after manifest.Parse(...). Consider setting it to nil once currentSpecs is built and the raw bytes are no longer needed.

Copilot uses AI. Check for mistakes.
Comment on lines 349 to 353
if d.includeTests {
newSpecs = manifest.Parse(string(installManifest), d.namespace, d.normalizeManifests)
newSpecs = manifest.Parse(installManifest, d.namespace, d.normalizeManifests)
} else {
newSpecs = manifest.Parse(string(installManifest), d.namespace, d.normalizeManifests, manifest.Helm3TestHook, manifest.Helm2TestSuccessHook)
newSpecs = manifest.Parse(installManifest, d.namespace, d.normalizeManifests, manifest.Helm3TestHook, manifest.Helm2TestSuccessHook)
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the intent is to reduce peak memory by allowing GC to reclaim the rendered manifest early, consider setting installManifest to nil after newSpecs is computed (before invoking the diff). As written, the raw buffer remains live for the rest of runHelm3.

Copilot uses AI. Check for mistakes.
Comment on lines 92 to 96
// create a diff between the current manifest and the version of the manifest that a user is intended to rollback
seenAnyChanges := diff.Manifests(
manifest.Parse(string(releaseResponse), namespace, d.normalizeManifests, excludes...),
manifest.Parse(string(revisionResponse), namespace, d.normalizeManifests, excludes...),
manifest.Parse(releaseResponse, namespace, d.normalizeManifests, excludes...),
manifest.Parse(revisionResponse, namespace, d.normalizeManifests, excludes...),
&d.Options,
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To reduce peak memory during the diff, consider dropping references to the large releaseResponse / revisionResponse byte slices after parsing (e.g., parse into locals, then set the byte slices to nil before calling diff.Manifests). As written, both raw buffers remain live for the duration of the diff.

Copilot uses AI. Check for mistakes.
- Replace append copy in Parse with io.MultiReader to avoid
 manifest copy
- Extract length ratio constants for named constants
  Add unit tests for length ratio heuristic
- Restructure cmd flows to parse into locals before diff

 release raw bytes earlier

Signed-off-by: yxxhero <[email protected]>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.

Comments suppressed due to low confidence (1)

diff/diff.go:221

  • The new length-ratio prefilter uses len(oldContent.Content)/len(newContent.Content), but contentSearch later calls decodeSecrets/redactSecrets, which mutate oldContent.Content/newContent.Content in-place. Because this runs inside the nested loop, content from earlier iterations can be modified before later ratio checks, making the ratio check (and diff fraction) depend on iteration order and potentially skipping valid rename candidates (notably for Secrets). Consider avoiding in-place mutation during the search (e.g., diff on temporary copies, or save/restore Content strings around secret handling) and only mutate in doDiff when emitting the final diff.
			oldLen := len(oldContent.Content)
			newLen := len(newContent.Content)
			if oldLen == 0 || newLen == 0 {
				continue
			}
			ratio := float32(oldLen) / float32(newLen)
			if ratio < renameDetectionMinLengthRatio || ratio > renameDetectionMaxLengthRatio {
				continue
			}

			switch {
			case options.ShowSecretsDecoded:
				decodeSecrets(oldContent, newContent)
			case !options.ShowSecrets:
				redactSecrets(oldContent, newContent)
			}

if releaseChart1 == releaseChart2 {
oldSpecs := manifest.Parse(releaseResponse1, namespace1, d.normalizeManifests, excludes...)
newSpecs := manifest.Parse(releaseResponse2, namespace2, d.normalizeManifests, excludes...)

Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Likewise, releaseResponse1/releaseResponse2 remain referenced after parsing into oldSpecs/newSpecs. If these raw manifest bytes aren’t used later, consider setting them to nil after parsing to reduce peak memory during diff.Releases(...) for large releases.

Suggested change
// Release raw manifest responses to reduce peak memory usage before diffing.
releaseResponse1 = nil
releaseResponse2 = nil

Copilot uses AI. Check for mistakes.
Comment on lines +1661 to +1662
opts := &Options{"diff", 10, false, true, false, []string{}, 0.5, []string{}}

Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new test uses a positional Options struct literal (&Options{"diff", 10, ...}), while the rest of this file uses keyed field names. Positional literals are brittle if the struct changes (field order/additions) and make the test harder to read. Prefer a keyed literal here for clarity and future-proofing.

Copilot uses AI. Check for mistakes.
Comment on lines 327 to 333
releaseManifest = append(releaseManifest, hooks...)
}
if d.includeTests {
currentSpecs = manifest.Parse(string(releaseManifest), d.namespace, d.normalizeManifests)
currentSpecs = manifest.Parse(releaseManifest, d.namespace, d.normalizeManifests)
} else {
currentSpecs = manifest.Parse(string(releaseManifest), d.namespace, d.normalizeManifests, manifest.Helm3TestHook, manifest.Helm2TestSuccessHook)
currentSpecs = manifest.Parse(releaseManifest, d.namespace, d.normalizeManifests, manifest.Helm3TestHook, manifest.Helm2TestSuccessHook)
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description mentions setting raw manifest byte slices to nil after parsing so GC can reclaim them before diff computation. In this code path, releaseManifest remains referenced after manifest.Parse(...), so the underlying bytes can’t be reclaimed until runHelm3 returns. If the slice isn’t needed after parsing (and after any three-way merge/hook appends), consider setting releaseManifest = nil once currentSpecs is built.

Copilot uses AI. Check for mistakes.
Comment on lines 348 to 353
var newSpecs map[string]*manifest.MappingResult
if d.includeTests {
newSpecs = manifest.Parse(string(installManifest), d.namespace, d.normalizeManifests)
newSpecs = manifest.Parse(installManifest, d.namespace, d.normalizeManifests)
} else {
newSpecs = manifest.Parse(string(installManifest), d.namespace, d.normalizeManifests, manifest.Helm3TestHook, manifest.Helm2TestSuccessHook)
newSpecs = manifest.Parse(installManifest, d.namespace, d.normalizeManifests, manifest.Helm3TestHook, manifest.Helm2TestSuccessHook)
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, installManifest remains referenced after parsing into newSpecs, so the raw rendered bytes won’t be eligible for GC until the end of runHelm3. If the manifest bytes aren’t used after this point (including the takeOwnership branch), consider setting installManifest = nil after newSpecs is created to match the PR’s stated memory-reclamation behavior.

Copilot uses AI. Check for mistakes.
// create a diff between the current manifest and the version of the manifest that a user is intended to rollback
oldSpecs := manifest.Parse(releaseResponse, namespace, d.normalizeManifests, excludes...)
newSpecs := manifest.Parse(revisionResponse, namespace, d.normalizeManifests, excludes...)

Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the intent is to allow GC reclamation of the raw manifest bytes before diffing (as described in the PR summary), releaseResponse/revisionResponse are still held for the remainder of backcastHelm3. After oldSpecs/newSpecs are built, consider setting those []byte variables to nil to let the GC drop the backing arrays earlier during potentially expensive diff processing.

Suggested change
// Allow GC to reclaim raw manifest bytes before potentially expensive diff processing.
releaseResponse = nil
revisionResponse = nil

Copilot uses AI. Check for mistakes.
Comment on lines 101 to +104

oldSpecs := manifest.Parse(revisionResponse, namespace, d.normalizeManifests, excludes...)
newSpecs := manifest.Parse(releaseResponse, namespace, d.normalizeManifests, excludes...)

Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this command path the raw []byte responses (releaseResponse, revisionResponse*) remain referenced after parsing, so they can’t be GC’d until the function returns. If the goal is to reduce peak memory during diff computation, consider setting these byte slices to nil after manifest.Parse(...) once you no longer need the raw manifest bytes.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Comment on lines +1686 to +1687
opts := &Options{"diff", 10, false, true, false, []string{}, 0.5, []string{}}

Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test uses an unkeyed Options composite literal, which is brittle and will break silently if the Options struct fields are reordered/extended. Prefer a keyed literal (e.g., set OutputFormat, OutputContext, ShowSecrets, FindRenames, etc. explicitly) to make the test resilient.

Copilot uses AI. Check for mistakes.
Comment on lines +1704 to +1705
opts := &Options{"diff", 10, false, true, false, []string{}, 0.5, []string{}}

Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test uses an unkeyed Options composite literal, which is brittle and will break silently if the Options struct fields are reordered/extended. Prefer a keyed literal (e.g., set OutputFormat, OutputContext, ShowSecrets, FindRenames, etc. explicitly) to make the test resilient.

Copilot uses AI. Check for mistakes.
Comment on lines +206 to +214
oldLen := len(oldContent.Content)
newLen := len(newContent.Content)
if oldLen == 0 || newLen == 0 {
continue
}
ratio := float32(oldLen) / float32(newLen)
if ratio < renameDetectionMinLengthRatio || ratio > renameDetectionMaxLengthRatio {
continue
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new length-ratio short-circuit runs before secrets are redacted/decoded. When ShowSecrets is false (or decoding is enabled), this can skip rename detection for Secrets whose raw YAML lengths differ a lot even though the post-processed (redacted/decoded) content would be comparable. Consider moving the ratio check to after the secret pre-processing step (or basing the ratio on the same content that diffMappingResults compares).

Copilot uses AI. Check for mistakes.
Comment on lines +1673 to +1674
opts := &Options{"diff", 10, false, true, false, []string{}, 0.5, []string{}}

Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test uses an unkeyed Options composite literal, which is brittle and will break silently if the Options struct fields are reordered/extended. Prefer a keyed literal (e.g., set OutputFormat, OutputContext, ShowSecrets, FindRenames, etc. explicitly) to make the test resilient.

Copilot uses AI. Check for mistakes.
@yxxhero
Copy link
Copy Markdown
Collaborator Author

yxxhero commented Mar 29, 2026

@copilot fix comments and review deeply.

- Nil out raw manifest byte slices after parsing in upgrade/release/rollback/revision
  to allow GC reclamation before diff computation
- Fix unkeyed Options struct literals in diff_test.go (4 locations) to use
  keyed field names for resilience against struct changes
- Skip length-ratio filter for Secrets in contentSearch() since Secret raw
  content lengths before redaction/decoding are unreliable rename predictors
- Extract empty-content check before the Secret kind branch (DRY)

Agent-Logs-Url: https://github.com/databus23/helm-diff/sessions/c2d2d0b3-3840-48a8-89f0-4b6913c83973

Co-authored-by: yxxhero <[email protected]>
Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 29, 2026

@copilot fix comments and review deeply.

Addressed all review comments in commit a8fed47:

  1. Nil out raw byte slices after parsing — added releaseManifest = nil / installManifest = nil in upgrade.go, releaseResponse1 = nil / releaseResponse2 = nil in release.go, and equivalent nil-outs in rollback.go and revision.go (both 1-revision and 2-revision cases). This lets the GC reclaim the large backing arrays before diff computation starts.

  2. Keyed Options struct literals — replaced all 4 unkeyed &Options{"diff", 10, ...} literals in diff_test.go with explicit field names (e.g. &Options{OutputFormat: "diff", OutputContext: 10, ShowSecrets: true, FindRenames: 0.5}), making them resilient to struct changes.

  3. Length-ratio filter for Secrets — the ratio check is now skipped when oldContent.Kind == "Secret", since a Secret's raw YAML length (with base64-encoded data) can be vastly different from its post-redacted/decoded length, making the ratio an unreliable predictor. The empty-content guard is hoisted out of the branch to avoid duplication.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

@yxxhero
Copy link
Copy Markdown
Collaborator Author

yxxhero commented Mar 29, 2026

@copilot fix ci issue.

Add //nolint:ineffassign comments on the nil assignments that are
intentionally written to allow GC to reclaim raw manifest bytes
before the diff computation begins. The ineffassign linter correctly
identifies these as never-read-after-assignment, but the intent is
a deliberate GC hint for long-running diff operations.

Agent-Logs-Url: https://github.com/databus23/helm-diff/sessions/aa2df3f5-57bb-4939-ac45-3f3612deac60

Co-authored-by: yxxhero <[email protected]>
Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 29, 2026

@copilot fix ci issue.

Fixed in commit fe9603d. The CI failure was ineffassign linter errors on all 10 nil assignments — it correctly identifies them as never-read-after-assignment, but the intent is a GC hint during long-running diff operations. Added //nolint:ineffassign // nil to allow GC to reclaim raw bytes before diff computation on each line to suppress the false-positive while preserving the explanation.

@yxxhero yxxhero merged commit 6851c7e into master Mar 29, 2026
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants