Skip to content

perf: concurrent render via a worker pool (remove process chdir)#5

Merged
vercel-eddie merged 1 commit into
mainfrom
perf/parallel-render
Jun 1, 2026
Merged

perf: concurrent render via a worker pool (remove process chdir)#5
vercel-eddie merged 1 commit into
mainfrom
perf/parallel-render

Conversation

@vercel-eddie

Copy link
Copy Markdown
Collaborator

Summary

veil render now renders its resources through a bounded goroutine pool (--jobs, default runtime.NumCPU()) instead of one at a time, and drops the process-global os.Chdir that previously made concurrency unsafe.

The chdir removal (the enabler)

The render pipeline used to os.Chdir(projectRoot) before running hooks so QuickJS's built-in std/os modules resolved paths (loadFile, readdir, …) against the project root — qjs mounts the process cwd at / when no root is given. That chdir is process-global, so it can't be shared safely across concurrent renders.

QuickJS already accepts the filesystem root per runtime via qjs.Option.CWD (mounted at /). So each hook runtime now mounts the project root directly through a new hook.WithCwd(root) option — the host process never changes its own working directory. The root is the directory housing veil.json/veil.yaml (a session constant), threaded from render.Options.Root.

The pool

  • runRender runs the paths through a bounded worker pool sized by --jobs (default NumCPU).
  • Results land in a fixed-index slice, so the JSON response order is deterministic regardless of completion order.
  • Per-resource path resolution stays relative to the user's cwd (no chdir to confuse it).

Shared state is already concurrency-safe: the kind registry and resource catalog both cache via sync.OnceValues, and each qjs runtime is independent.

Verification

  • Byte-identical output vs the chdir implementation (re-rendered api-devbox through render.sh, no diff).
  • 0 data races rendering 300 services at --jobs 16 under go build -race (and go test -race ./pkg/render ./pkg/commands).
  • Full suite + go vet green.

Scale results (300 services, 14 cores)

Mode Time vs old
300 separate veil invocations (pre-batching) 139s
batched, --jobs 1 (registry parsed once) 44s 3.2×
--jobs 4 15s 9.3×
--jobs 8 11s 12.6×
--jobs 14 (= default NumCPU) ~9s ~15×

Two compounding wins: batching one invocation (3.2×) and then the pool (~5× more), ~15× overall, tapering once CPU-bound on qjs hook execution past the core count.

🤖 Generated with Claude Code

`veil render` now renders its paths through a bounded goroutine pool
(--jobs, default NumCPU) instead of sequentially. Results are collected
in fixed-index order so the response stays deterministic.

The enabler is removing the process-global `os.Chdir(root)` the render
pipeline used so hook `std`/`os` modules resolved paths against the
project root. QuickJS already accepts the filesystem root per runtime
(qjs.Option.CWD, mounted at "/"), so each hook now mounts the project
root directly via the new hook.WithCwd option — nothing process-global
is touched, which is what makes concurrent renders safe. render.Options
loses the short-lived SkipChdir field; Render no longer chdir's at all.

Verified: byte-identical output vs the chdir implementation, and 0 data
races rendering 300 services at --jobs 16 under the race detector.

Scale (300 services, 14 cores): 300 separate invocations 139s →
batched --jobs 1 44s (3.2x, registry parsed once) → --jobs 14 ~9s
(~15x) as the pool saturates the cores.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel-eddie vercel-eddie merged commit 86c747e into main Jun 1, 2026
3 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.

1 participant