Skip to content

Feature request: alter and insert on a let-local, in place (growing a local copies every step today) #950

Open

Description

@vbmithr

Summary

Rayfall cannot update a let local or a lambda parameter in place.

The only linear way to grow or amend a value is therefore a global. #821 made that fast for dotted globals. It also means any engine that accumulates state, such as a backtester's run state, has to keep it in globals, while the other q-family runtimes keep it in locals.

Reproduction

The loop runs 500 instruments, with a 500-row append and one 10-element amend per step, on rayforce-git 2.10.0.r217.g014b98ad:

(set N 500)
(set syms (take [a b c d e f g h i j] N))
(set mkP (fn [] (table [ts sym qty] (list (as 'I64 []) (as 'SYM []) (as 'I64 [])))))

;; global: linear
(set runG (fn [steps]
  (set bt {}) (set bt.P (mkP)) (set bt.q (take 0 N)) (set k 0)
  (while (< k steps)
    (insert 'bt.P (list (take k N) syms bt.q))
    (alter 'bt.q set (til 10) (+ (take k 10) 1))
    (set k (+ k 1)))
  (count bt.P)))

;; local: no in-place form, so the table is copied every step
(set runL (fn [steps]
  (let P (mkP)) (let q (take 0 N)) (let k 0)
  (while (< k steps)
    (let P (insert P (list (take k N) syms q)))
    (let q (+ q (concat (take 1 10) (take 0 (- N 10)))))
    (let k (+ k 1)))
  (count P)))
1,000 steps 2,000 steps (1M rows)
runG 6.5 ms 11.4 ms
runL 5,037 ms 20,715 ms

Other q-family runtimes

The same loop in each runtime's idiom (P,:rows and q[i]:v on a local), timed at 1,000 → 2,000 steps, in ms:

Global, in place Local, in place
kdb+ 5.0 2 → 12 2 → 6
peachq (q on Rayforce) 17 → 38 17 → 36
amber (on ngn/k) 1 → 3 1 → 2
Rayfall 6.5 → 11.4 not expressible; the functional form takes 5,037 → 20,715

peachq runs on the Rayforce runtime, so the runtime already supports this. Its modassign_eval (src/qlang/eval/q_eval.c) takes the value out of the local's slot (q_env_local_take), so the binding is its only owner. It then appends in place (q_join_amend) and stores the result back.

Proposal

Let alter and insert amend a let local or a parameter of the enclosing lambda in place, with the same forms they take for globals:

(let P (mkP))
(insert 'P rows)              ; appends to the local P
(alter 'q set idx vals)       ; amends the local q
(alter 'o concat vals)

Use case

The Rayfall backtester keeps a run's whole state (positions, fills, records) in a global dictionary, because only globals can be updated in place. Local state would let one process hold several runs, and it would make the engine reentrant. It is also what a real pmap (#948) would need: in kdb+ and amber, parallel workers may read globals but not assign them (noupdate), so per-run state must live in locals.

A dictionary passed into a function and returned is quadratic in every runtime above (kdb+ 120 → 1,258 ms), so the request is only about locals, not about functional state passing.

Activity

  1. morsel-ray commented on Oct 22, 2026

    @morsel-ray
    Collaborator

    The local-state/performance use case is compelling. There is also a correctness wrinkle to separate from accepting the new scope policy: quoted insert still has the old read-local/write-global mismatch that #846 rejected for alter.

    I executed this small case using the preserved accepted014b ASan binary; the relevant query.c, tblop.c, env.c and eval.c blobs match accepted62d9:

    (set vg [50 60])
    (set vf (fn [] (let vg (til 2)) (insert 'vg 9) vg))
    (set vr (vf))
    (list vr vg)
    ; actual: ([0 1] [0 1 9])

    The local is unchanged and the unrelated global [50 60] is replaced. A two-row local-table variant likewise returns2 local rows while replacing the same-named global with3 rows. Source matches the result: named insert reads through ray_env_get, then its rebuild/append publication uses ray_env_set. Exit0/no sanitizer warning doesn't make that scope behavior safe. I haven't repeated your timings or the other runtimes' measurements.

    For the feature decision, #846's existing explanation explicitly makes quoted mutators global-targeting; changing them to read-like lexical resolution is a language contract choice, not just an ownership optimization. Once selected, the update must resolve one binding and publish back to that same binding—never fall through to a same-named global on a local/error path.

    I'd want the ownership tests to cover aliases/closures, slices and their parents, and errors after detaching/preparing the value, alongside a uniquely owned local's actual fast-path engagement. Header refcounts alone aren't an isolation proof (the slice case in #943 was a recent example). A PeachQ-style slot take is a useful implementation lead, but needs checking against Rayfall's frame/VM ownership and failure contracts.

    Local updates are a useful building block for independent runs; they wouldn't by themselves establish engine reentrancy or parallel-VM safety. I'd keep that #948 design separate. Are you planning an implementation here? I haven't claimed or started one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions