crossplane-contrib/function-kcl

Input is serialized to YAML, re-parsed, then re-serialized to JSON on every call — ~43% of CPU is this round trip

Geschlossen

#407 geöffnet am 13.07.2026

 (0 Kommentare) (0 Reaktionen) (0 zugewiesene Personen)Go (42 Forks)auto 404
bughelp wanted

Repository-Metriken

Stars
 (86 Sterne)
PR-Merge-Metriken
 (PR-Metriken ausstehend)

Beschreibung

What happened?

Every RunFunction call converts its input through YAML and back for no reason, and the cost is not small.

 fn.go:117-203
   observed / desired  ──json.Marshal──▶  RawExtension{Raw: []byte}          ❶  we have JSON
                                                    │
                                                    │  (sigs.k8s.io/yaml.Marshal,
                                                    │   which is itself json.Marshal + JSONToYAML)
 fn.go:209                                          ▼
   KCLRun YAML document  ~164 KB  ◀────────────────────────────────────────  ❷  JSON ▶ YAML
                                                    │
                                                    │  kio.NewPipeline(...).Execute()
 krm-kcl  pkg/kio/filter.go                         ▼
   kyaml RNode tree      ~87 MB of allocations  ◀───────────────────────────  ❸  YAML ▶ parse   (22% CPU)
                                                    │
                                                    │  ToKCLValueString() = RNode.MarshalJSON()
 krm-kcl  pkg/edit/opts.go:96-104                   ▼
   -D resource_list=<JSON>                                                    ❹  RNode ▶ JSON   (21% CPU)
   -D items=<JSON>
   -D params=<JSON>          ← same bytes as resource_list.functionConfig.spec.params
                                                    │
                                                    │  protobuf ExecProgramArgs ▶ FFI
 kcl-lang.io/lib                                    ▼
   Rust libkcl: parse the JSON args, compile prog.k, evaluate                 ❺  wants JSON     (10% CPU)

We hold JSON at ❶. The runtime wants JSON at ❺. Steps ❷, ❸ and ❹ are pure conversion — and they are ~43% of the CPU, four times what the actual KCL compiler costs. They also produce the 87 MB/op allocation churn that drives another 24% of CPU into the GC.

The YAML in the middle exists for one reason only: krm-kcl's entrypoint is a byte-stream KRM function pipeline, so the sole way to hand it data is to render a YAML manifest and let it re-parse it.

fn.go already holds the entire input as JSON — in.Spec.Params is a map[string]runtime.RawExtension, and every entry was built by json.Marshal in pkgresource.UnstructuredToRawExtension / ObjToRawExtension (fn.go:117-203). Then:

  1. fn.go:209yaml.Marshal(in) converts that JSON-backed struct into a YAML document.
  2. krm-kcl parses the YAML text back into kyaml RNodes (pkg/kio/filter.go, via kio.ByteReadWriter).
  3. krm-kcl@v0.12.4/pkg/edit/opts.go:53-107 (constructOptions) walks those RNodes and calls ToKCLValueString, which does MarshalJSON — converting them back to JSON — to build the KCL top-level arguments:
opts.Arguments = append(opts.Arguments,
    fmt.Sprintf("%s=%s", resourceListOptionName, resourceListOptionKCLValue),
    fmt.Sprintf("%s=%s", itemsOptionName, itemsOptionKCLValue),
    fmt.Sprintf("%s=%s", paramsOptionName, paramsOptionKCLValue),
    ...
)
  1. KCL parses those JSON strings.

So the data goes JSON → YAML → (parse) → RNode → JSON, per call. Steps 1–3 are pure overhead: the JSON that comes out at step 3 is semantically the JSON we already had at step 0.

Measured. Benchmarking kio.NewPipeline(...).Execute() exactly as fn.go:226-227 calls it, with a 164 KB input (a realistic CloudNativePG-shaped observed state) and a 591-byte KCL source, on main (59ce599), Go 1.25, linux/amd64, CGO_ENABLED=0:

128 ms/op, 87 MB allocated per op, 499k allocs per op

A 164 KB input allocating 87 MB is the tell. CPU profile:

share of CPU
YAML parsing (go.yaml.in/yaml/v3) 22 %
constructOptionsToKCLValueString (RNode → JSON) 21 %
runtime.gcBgMarkWorker — GC driven by the allocation churn 24 %
runtime.cgocall — the entire native KCL compile + eval 10.4 %

The YAML/JSON round trip alone is ~43 % of CPU — four times what the actual KCL compiler costs — and it is the direct cause of most of the allocation churn feeding the GC.

How can we reproduce it?

in := &fkcl.KCLInput{}
in.APIVersion, in.Kind = v1alpha1.KCLRunAPIVersion, api.KCLRunKind
in.Spec.Source = source
in.Spec.Params = map[string]runtime.RawExtension{ /* oxr, dxr, ocds, dcds, ctx, ... */ }
b, _ := yaml.Marshal(in)

out := bytes.NewBuffer(nil)
kio.NewPipeline(bytes.NewBuffer(b), out, false).Execute()

Feed it a params payload of ~150 KB (a CNPG Cluster with a populated status.instancesStatus works well), run under -cpuprofile, and look at the go.yaml.in/yaml/v3 and ToKCLValueString cumulative shares. Happy to contribute the benchmark.

What environment did it happen in?

function-kcl version: current main (59ce599)

  • kcl-lang.io/krm-kcl v0.12.4, kcl-lang.io/lib v0.12.3
  • Crossplane v2.4.0-rc.0, Kubernetes 1.30
  • Go 1.25, linux/amd64, CGO_ENABLED=0

Possible fix

Short-circuit ❷–❹: go from the JSON we already have straight to the JSON the runtime wants.

   RawExtension{Raw: []byte}  ──────────────────────▶  -D params=<JSON>  ──▶  Rust libkcl
        (already JSON)              no YAML,                (same bytes)
                                    no RNode,
                                    no re-marshal

Concretely this needs a krm-kcl API that accepts already-marshalled input instead of a YAML byte stream — e.g. a variant of RunKCLWithConfig that takes the params as pre-serialized JSON strings and hands them straight to opts.Arguments. function-kcl would call that instead of kio.NewPipeline.

Two smaller, independent wins in the same path:

  • resource_list already embeds functionConfig.spec.params, and params is then passed again as a separate argument (opts.go:96-104). The same bytes are serialized twice per call.
  • SourceToTempEntry (krm-kcl/pkg/edit/bootstrap.go:151-161) does an os.MkdirTemp + os.WriteFile + os.RemoveAll per call to hand KCL a file path, even though ExecProgramArgs supports passing source directly via KCodeList. Small next to the payload cost, but it's per-call filesystem I/O on a hot path, and the random path defeats any content-independent caching downstream.

I realise the round trip is load-bearing for krm-kcl's KRM-function use case, so the fix probably belongs there as an additional entrypoint rather than a change to the existing one. Happy to discuss the shape and send PRs to both repos.

Contributor Guide