Skip to content

Nickel keymap-ncl-to-json is too slow; explore native lowering to keymap.json #655

Description

@rgoulter

Below was from an exploration with an LLM investigating which part is slow in the current compilation process.

Key summary

Using Nickel for the keymap-ncl-to-json compilation pass is too slow for complex keymaps.

  • We'll still keep keymap.ncl for authoring keymaps.
  • Treat keymap.json as the stable IR.
  • Explore native lowering to that IR.
  • JSON → Rust source codegen (keymap-codegen) is comparatively cheap and can stay in Nickel for now.

Product constraints

Layer Decision Why
keymap.ncl authoring + contracts Keep (must) Preferred authoring surface; contract validation belongs with authoring
Dense keymap.json Keep as stable IR Real API: serde / Cucumber / full-profile Vec system already consume it
ncl → json passes in Nickel Too slow; explore native ~10s on rich maps; tiny IR; interpreter tax
json → rust codegen in Nickel Defer rewrite ~1s; not the bottleneck; fak/kirei lineage still works

Measurements (casual wall-clock)

Casual profiling only (time around nickel export / existing scripts). Nickel 1.16 has no built-in profiler. Each --field export is a cold process. (Although I'd note: the full integration test suite is affected by cold processes). Order-of-magnitude; local machine (M4 Macbook Air) measured on 2026-07-26.

(tests/ncl/keymap-36key-rgoulter is my keymap; relatively sophisticated. Miryoku-ish, with some chording).

Fixture focus: tests/ncl/keymap-36key-rgoulter (real-ish layered map: 36 keys, ~28 KB JSON — not a large IR).

Stage What Wall
Binary start nickel --version ~4 ms
Load / surface only --field=layers (almost no passes) ~1.0–1.3 s
Full ncl → json keymap-ncl-to-json.sh / --field=json_keymap ~10 s (rich 36-key)
json → rust keymap-codegen.sh ~1.2 s
Simpler maps ncl → json e.g. 2-key named / 48-key basic ~1–2.5 s

Stage walk (36-key rgoulter; cold export per field; Δ ≈ new work after shared load):

Forced field Wall Approx marginal
layers ~1.5 s load + author surface
layered_keys / chorded_keys ~1.6–2.3 s early passes
automation_transform ~4.2 s +automation
json_keymap ~10 s +~6 s named-layers, pad, to_json_value

Reading: startup is negligible. A large fixed cost (~1s) is loading/evaluating the Nickel program graph. Variable cost scales with keymap richness, not raw key count alone (48-key basic ~2.3s vs 36-key rich ~10s). The bulk of the expensive path sits after automation_transform, inside json_keymap.

This is not “slow because the problem instance is huge.” Same logical work in a native language on a 36-node IR should be milliseconds-class; Nickel pays large constant factors (interpreter, contracts, repeated tree walks, dynamic key-module dispatch, record/array updates).

Target shape

keymap.ncl  --[Nickel: author + contracts; stop before heavy passes]-->  handoff
            --[native: lower to dense keymap.json]--------------------->  keymap.json
            --[Nickel or later Rust: codegen]-------------------------->  keymap.rs
                 ~1 s?                         ~ms–tens of ms              ~1 s

A product budget of ~1s Nickel + cheap native lower + ~1s codegen is reasonable for keymap/firmware builds once the hot passes leave Nickel.

Methodology (repro)

time ncl/scripts/keymap-ncl-to-json.sh tests/ncl/keymap-36key-rgoulter
time ncl/scripts/keymap-codegen.sh tests/ncl/keymap-36key-rgoulter

for f in layers layers_of_keys layered_keys chorded_keys \
         automation_transform json_keymap; do
  echo -n "$f  "
  time nickel export --format=json \
    --import-path=ncl --import-path=tests/ncl/keymap-36key-rgoulter \
    keymap-ncl-to-json.ncl keymap.ncl --field="$f" >/dev/null
done

Optional: add temporary exportable intermediates inside json_keymap (post–named-layers, post-pad, post–to_json_value) to split the ~6s tail further.

Recommended exploration

Spike (primary): Nickel keeps authoring + contracts; export a handoff IR; Rust lowers to dense keymap.json with snapshot parity against tests/ncl/**/keymap.json.

Related paths

  • Frontend: ncl/keymap-ncl-to-json.ncl, ncl/passes/*
  • Backend: ncl/keymap-codegen.ncl, ncl/key_system/
  • Scripts: ncl/scripts/keymap-ncl-to-json.sh, keymap-codegen.sh
  • Helper (combined eval): smart-keymap-nickel-helper
  • Local write-up: notes/nickel-compiler-passes-perf.org

Discussion: Nickel used for three roles

Nickel currently does three jobs that got conflated:

  1. Authoring DSL — keep.
  2. Compiler frontend (ncl → dense JSON) — too slow; main target.
  3. Compiler backend (JSON → Rust source) — acceptable cost; defer.

(1) and (2) sharing a language was interesting and coherent with fak/kirei; measurements say (2) is not worth keeping in Nickel long-term if multi-second (or 10s+) maps are normal.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions