Summary
For large CDK (Python) apps, synthesis time scales roughly linearly with app size, and the host↔runtime wire protocol is a prime suspect. I profiled a real synth on both sides of the protocol. The finding: the wire format (JSON) is not the bottleneck — the synchronous, one-round-trip-per-operation structure is. Each side sits idle roughly half the time waiting for the other. There is a credible ~2× headroom, but capturing it requires pipelining the protocol (overlapping round-trips), which in turn requires optimistic / client-allocated object references.
This is the remaining big lever after assembly-load and kernel hot-path improvements (see related items below).
Method
- App: a synthetic CDK Python stack, ~2,200 resources → 16,827 kernel requests.
- Captured the full request stream; classified responses by replaying through
KernelHost.
- Instrumented the Node child runtime (
InputOutput) to bucket time into idle (blocked on readLine) / JSON.parse / JSON.stringify / pipe write / work (remainder).
- Instrumented the Python client
send() to bucket time into serialize / write / wait (blocked on the runtime) / structure, and to attribute wait by api.
- Numbers are from one app on one machine; treat magnitudes as indicative, not exact. (Happy to share the harness.)
Message mix
| api |
share |
count |
| create |
38.2% |
6,421 |
| invoke |
33.3% |
5,601 |
| get |
14.3% |
2,400 |
| sinvoke |
9.5% |
1,600 |
| sget |
4.8% |
800 |
| set / sset / del |
0% |
0 |
CDK configures objects through constructor props, so there are no property sets — the volume is creates and (value-returning) invokes.
Where the time goes
Node / child runtime (full synth, ~5.7 s wall):
| bucket |
ms |
share |
| work (kernel dispatch + construct JS + load) |
~2,880 |
~50% |
idle (blocked on readLine, waiting for host) |
~2,700 |
~47% |
JSON.parse |
~38 |
0.7% |
JSON.stringify |
~7 |
0.1% |
| pipe write |
~74 |
1.3% |
Python client send(): total 4.31 s, of which wait (blocked on runtime) 4.00 s (93%), serialize (cattrs + json) 0.19 s, structure 0.08 s, write 0.04 s. Wait by api: invoke 1.90 s, create 1.42 s, load 0.39 s, get 0.14 s, sinvoke 0.10 s, sget 0.06 s.
Conclusions
- Serialization/format is not the bottleneck. JSON encode+decode+write is ~2% of Node time and ~6% of Python time. A binary protocol (protobuf/msgpack/etc.) would chase single-digit percentages at the cost of cross-language complexity. Not worth it.
- It's a synchronous ping-pong. Wall time ≈
python_work + node_work + handoff, fully serialized; each side is idle ~half the time. With perfect overlap the floor is ~max(python_work, node_work) ≈ Node's 2.88 s vs today's ~5.7 s → ~2× ceiling. Of the 4.0 s the host waits, ~2.88 s is the runtime actually working and ~1.1 s is pure handoff across 16,827 round-trips (~65 µs/msg) — partly attributable to the extra parent→child fd3 relay hop in bin/jsii-runtime.js.
- The pipelineable volume is creates and invokes. In this app, 100% of invokes/sinvokes (7,200) return object references that are used structurally (passed to later calls) and never inspected — like creates. So ~3.4 s of the 4.0 s host wait is on operations whose result is an objref the host doesn't actually need to read; only
get/sget (~0.2 s) are hard synchronization points.
Proposed direction: pipeline the protocol via client-allocated object references
The trivial "fire-and-forget void ops" idea does not apply (no void volume). To overlap creates/invokes, the host must be able to use a returned objref before the runtime confirms it. The robust, non-fragile mechanism is client-allocated object ids:
- The host mints the object id and sends it with
create (and objref-returning invoke); the kernel honors the provided id instead of allocating from its #nextid counter.
- The host does not block on the response; it continues, using the id it just minted.
- Acks are drained lazily, with a cap on outstanding requests to avoid pipe-buffer deadlock; the host blocks (drains) only at true sync points:
get/sget, callbacks, and end of synth.
Tradeoffs to design for:
- Error attribution shifts: a failed pipelined op surfaces at the next sync point, not the call site. Requests would need tagging to map deferred errors back.
- Callbacks (runtime → host overrides) are hard sync points and must drain/coordinate the pipeline.
- Ordering / id-space: the kernel must accept and trust client ids (in a defined range) without collisions.
- Two-sided change (kernel + each language runtime); ideally introduced behind a capability negotiated at the load/hello handshake so old and new peers interoperate.
Suggested next step
Prototype behind a flag: kernel accepts a client-supplied objid; Python client allocates ids, pipelines create/invoke, and drains lazily at sync points (relaxed error handling for the spike). Measure real wall-time against the ~5.7 s baseline to confirm the ceiling.
Related
This issue tracks the wire-protocol/round-trip dimension specifically.
Summary
For large CDK (Python) apps, synthesis time scales roughly linearly with app size, and the host↔runtime wire protocol is a prime suspect. I profiled a real synth on both sides of the protocol. The finding: the wire format (JSON) is not the bottleneck — the synchronous, one-round-trip-per-operation structure is. Each side sits idle roughly half the time waiting for the other. There is a credible ~2× headroom, but capturing it requires pipelining the protocol (overlapping round-trips), which in turn requires optimistic / client-allocated object references.
This is the remaining big lever after assembly-load and kernel hot-path improvements (see related items below).
Method
KernelHost.InputOutput) to bucket time into idle (blocked onreadLine) /JSON.parse/JSON.stringify/ pipe write / work (remainder).send()to bucket time into serialize / write / wait (blocked on the runtime) / structure, and to attribute wait by api.Message mix
CDK configures objects through constructor props, so there are no property sets — the volume is creates and (value-returning) invokes.
Where the time goes
Node / child runtime (full synth, ~5.7 s wall):
readLine, waiting for host)JSON.parseJSON.stringifyPython client
send(): total 4.31 s, of which wait (blocked on runtime) 4.00 s (93%), serialize (cattrs +json) 0.19 s, structure 0.08 s, write 0.04 s. Wait by api: invoke 1.90 s, create 1.42 s, load 0.39 s, get 0.14 s, sinvoke 0.10 s, sget 0.06 s.Conclusions
python_work + node_work + handoff, fully serialized; each side is idle ~half the time. With perfect overlap the floor is ~max(python_work, node_work)≈ Node's 2.88 s vs today's ~5.7 s → ~2× ceiling. Of the 4.0 s the host waits, ~2.88 s is the runtime actually working and ~1.1 s is pure handoff across 16,827 round-trips (~65 µs/msg) — partly attributable to the extra parent→child fd3 relay hop inbin/jsii-runtime.js.get/sget(~0.2 s) are hard synchronization points.Proposed direction: pipeline the protocol via client-allocated object references
The trivial "fire-and-forget void ops" idea does not apply (no void volume). To overlap creates/invokes, the host must be able to use a returned objref before the runtime confirms it. The robust, non-fragile mechanism is client-allocated object ids:
create(and objref-returninginvoke); the kernel honors the provided id instead of allocating from its#nextidcounter.get/sget, callbacks, and end of synth.Tradeoffs to design for:
Suggested next step
Prototype behind a flag: kernel accepts a client-supplied objid; Python client allocates ids, pipelines create/invoke, and drains lazily at sync points (relaxed error handling for the spike). Measure real wall-time against the ~5.7 s baseline to confirm the ceiling.
Related
This issue tracks the wire-protocol/round-trip dimension specifically.