Input is serialized to YAML, re-parsed, then re-serialized to JSON on every call — ~43% of CPU is this round trip
#407 opened on Jul 13, 2026
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:
fn.go:209—yaml.Marshal(in)converts that JSON-backed struct into a YAML document.krm-kclparses the YAML text back into kyamlRNodes (pkg/kio/filter.go, viakio.ByteReadWriter).krm-kcl@v0.12.4/pkg/edit/opts.go:53-107(constructOptions) walks thoseRNodes and callsToKCLValueString, which doesMarshalJSON— 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),
...
)
- 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 % |
constructOptions → ToKCLValueString (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-kclv0.12.4,kcl-lang.io/libv0.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_listalready embedsfunctionConfig.spec.params, andparamsis 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 anos.MkdirTemp+os.WriteFile+os.RemoveAllper call to hand KCL a file path, even thoughExecProgramArgssupports passing source directly viaKCodeList. 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.