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

Closed

#407 opened on Jul 13, 2026

 (0 comments) (0 reactions) (0 assignees)Go (42 forks)auto 404
bughelp wanted

Repository metrics

Stars
 (86 stars)
PR merge metrics
 (PR metrics pending)

Description

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