elly_core/eval_env.rs
1//! Evaluation environment and context: the persistent [`EnvNode`] chain, the
2//! [`Ctx`] threading the unique-id source and builtin-module cache, and [`instantiate`],
3//! which builds a module instance's environment terminal. The tree-walker in `eval.rs`
4//! reads bindings via [`lookup`], conses them with [`bind`], and captures closures
5//! via [`capture_env`].
6
7use alloc::boxed::Box;
8use alloc::rc::Rc;
9use alloc::vec::Vec;
10use core::cell::{Cell, OnceCell};
11
12use crate::ast::{Capture, Expr, ModuleData, Text};
13use crate::{HomeLeaf, Raised};
14
15use crate::builtins::NMODULES;
16use crate::eval::{self, Builtin};
17use crate::value::Value;
18
19/// A shared, persistent environment: a nameless cons list of the running
20/// activation's bindings (innermost first) on top of the closure's captured
21/// [`Frame`](EnvNode::Frame). A resolved reference ([`Expr::Local`](crate::Expr))
22/// reaches its value by walking its de Bruijn `index` over `next` links and
23/// cloning the cell's value — a pointer chase without name comparison or
24/// per-link cloning. Indices beyond the consed cells land in the frame array.
25/// The resolver (`resolve.rs`) assigns each reference the exact number of cells
26/// between it and its binder.
27#[derive(Debug)]
28#[cfg_attr(not(feature = "stats-env"), derive(Clone))]
29pub struct Env(Option<EnvRef>);
30
31/// With `stats-env`, cloning passes the caller's location on to [`EnvRef`]'s clone,
32/// which a derived impl (through `Option::clone`) would lose.
33#[cfg(feature = "stats-env")]
34impl Clone for Env {
35 #[track_caller]
36 #[inline]
37 fn clone(&self) -> Self {
38 match &self.0 {
39 Some(node) => Env(Some(node.clone())),
40 None => Env(None),
41 }
42 }
43}
44
45impl Env {
46 pub const EMPTY: Env = Env(None);
47
48 /// Cons one binding of `val` onto `env`.
49 pub fn bind(self, val: Value) -> Self {
50 Self(Some(EnvRef::new(EnvNode::Cons { val, next: self })))
51 }
52
53 /// [`bind`](Self::bind) in the evaluator: the cell comes from `ctx`'s free
54 /// list when it has one.
55 #[inline]
56 pub(crate) fn bind_in(self, val: Value, ctx: &Ctx) -> Self {
57 Self(Some(ctx.alloc(EnvNode::Cons { val, next: self })))
58 }
59
60 /// Cons one binding of `val` onto `env`.
61 pub(crate) fn frame(self, vals: Box<[Value]>) -> Self {
62 Self(Some(EnvRef::new(EnvNode::Frame { vals, home: self })))
63 }
64
65 pub(crate) fn from_ref(node: EnvRef) -> Self {
66 Self(Some(node))
67 }
68
69 pub fn clone_ref(&self) -> Option<EnvRef> {
70 self.0.clone()
71 }
72
73 pub(crate) fn as_module(&self) -> Option<(&Rc<ModuleData>, u64)> {
74 match self.0.as_deref() {
75 Some(EnvNode::Module { data, id_base, .. }) => Some((data, *id_base)),
76 _ => None,
77 }
78 }
79
80 /// Resolves a de Bruijn `index` to a cloned value by walking `next` links.
81 /// If the walk reaches the [`Frame`], the remaining index selects `vals[rest]`.
82 /// The resolver ensures the index lands on a live cell or frame slot.
83 ///
84 /// [`Module`](EnvNode::Module) terminals are passed through uncounted; a module
85 /// body's reference to an import or prelude value is a `Local` whose index runs
86 /// into the frame.
87 ///
88 /// [`Frame`]: EnvNode::Frame
89 pub(crate) fn lookup(&self, index: u32) -> Value {
90 let mut cur = self;
91 let mut rest = index as usize;
92 loop {
93 match cur
94 .0
95 .as_deref()
96 .expect("resolved index walked past the environment")
97 {
98 EnvNode::Cons { val, next } => {
99 if rest == 0 {
100 return val.clone();
101 }
102 rest -= 1;
103 cur = next;
104 }
105 EnvNode::Frame { vals, .. } => match vals.get(rest) {
106 Some(val) => return val.clone(),
107 None => unreachable!("resolved index walked past the captured frame"),
108 },
109 EnvNode::Module { frame, .. } => cur = frame,
110 }
111 }
112 }
113
114 /// Returns the environment's terminal: either `None` or the [`Module`](EnvNode::Module)
115 /// node. A closure built here carries it as its frame's `home`, ensuring a module
116 /// item's body can find its module without new allocations.
117 fn find_home(&self) -> Env {
118 let mut cur = self;
119 loop {
120 match cur.0.as_deref() {
121 Some(EnvNode::Cons { next, .. }) => cur = next,
122 Some(EnvNode::Frame { home, .. }) => return home.clone(),
123 Some(EnvNode::Module { .. }) => return cur.clone(),
124 None => return Env::EMPTY,
125 }
126 }
127 }
128
129 /// Walks to the module terminal at `depth` and returns a home-module leaf.
130 ///
131 /// Leaf addresses the terminal itself rather than a member. The constructor `#Self()`
132 /// and `.Self` (unwrapping inner data) are object builtins pre-applied to the module,
133 /// returning a partial application that takes one remaining argument.
134 #[inline(never)]
135 pub(crate) fn home_leaf(&self, leaf: HomeLeaf, depth: u32) -> Result<Value, eval::Raised> {
136 let (_, home, _, _) = self
137 .find_module(depth)
138 .ok_or_else(|| Raised::no_match("no_module"))?;
139 let home = Value::Module(home.clone());
140 Ok(match leaf {
141 HomeLeaf::Module => home,
142 HomeLeaf::New => Value::Builtin {
143 op: Builtin::ObjNew,
144 args: alloc::vec![home],
145 },
146 HomeLeaf::Value => Value::Builtin {
147 op: Builtin::ObjValue,
148 args: alloc::vec![home],
149 },
150 })
151 }
152
153 pub(crate) fn find_module(
154 &self,
155 mut depth: u32,
156 ) -> Option<(&Env, &EnvRef, &Rc<ModuleData>, u64)> {
157 let mut cur = self;
158 while let Some(node) = &cur.0 {
159 match &**node {
160 EnvNode::Cons { next, .. } => cur = next,
161 EnvNode::Frame { home, .. } => cur = home,
162 EnvNode::Module {
163 data,
164 id_base,
165 frame,
166 } => {
167 if depth == 0 {
168 return Some((cur, node, data, *id_base));
169 }
170 depth -= 1;
171 cur = frame;
172 }
173 }
174 }
175 None
176 }
177
178 /// Builds the environment for a capturing closure from its resolved `plan`.
179 /// Outlined from `eval_env` to optimize instruction cache for deep-recursion
180 /// benchmarks.
181 #[inline(never)]
182 pub(crate) fn capture_env(&self, plan: &[Capture], ctx: &Ctx) -> Self {
183 match plan {
184 [] => self.find_home(),
185 // A one-value frame *is* a `Cons` onto the terminal: index 0 is the capture
186 // and the walk ends there either way. Reusing the node the environment
187 // already has costs one allocation instead of two (node + values), and
188 // retains exactly the one value — worth a case of its own because a single
189 // capture is the common escaping closure (`&v (x x) v` in a Z-combinator
190 // recursion). Two or more go through a frame: the values then share one
191 // allocation and one walk, which conses cannot.
192 [c] => {
193 let (val, next) = lookup_and_home(self, c.outer);
194 next.bind_in(val, ctx)
195 }
196 _ => {
197 let (vals, home) = build_frame_and_home(self, plan);
198 home.frame(vals)
199 }
200 }
201 }
202}
203
204/// A shared reference to an [`EnvNode`]: every handle on a node — an [`Env`] link,
205/// a [`Value::Module`], an iota's or object's prototype — goes through it.
206///
207/// Today it is a bare `Rc`, but as the one place nodes are allocated, cloned and
208/// compared, it is where to hook instrumentation (see [`stats_env`]) or to try
209/// another representation (a plain `Box`, a pool). It must stay one non-null word
210/// so `Option<EnvRef>` keeps the niche.
211#[derive(Debug)]
212#[cfg_attr(not(feature = "stats-env"), derive(Clone))]
213#[repr(transparent)]
214pub struct EnvRef(Rc<Shared>);
215
216/// What an [`EnvRef`] points at: the node itself, or with `stats-env` the node
217/// plus the highest reference count it has reached.
218#[cfg(not(feature = "stats-env"))]
219type Shared = EnvNode;
220#[cfg(feature = "stats-env")]
221type Shared = stats_env::Tracked;
222
223impl EnvRef {
224 #[inline]
225 pub(crate) fn new(node: EnvNode) -> Self {
226 #[cfg(feature = "stats-env")]
227 let node = stats_env::Tracked::new(node);
228 Self(Rc::new(node))
229 }
230
231 /// Whether `a` and `b` are the same node: the identity of a module instance.
232 #[inline]
233 pub fn ptr_eq(a: &Self, b: &Self) -> bool {
234 Rc::ptr_eq(&a.0, &b.0)
235 }
236
237 /// The node's address, for hashing by identity.
238 #[inline]
239 pub fn as_ptr(this: &Self) -> *const EnvNode {
240 &**this
241 }
242
243 /// Whether this is the node's only reference, so it dies with this handle.
244 #[inline]
245 fn is_unique(&self) -> bool {
246 Rc::strong_count(&self.0) == 1 && Rc::weak_count(&self.0) == 0
247 }
248
249 /// The node, if this is its only reference: it can then be rewritten in
250 /// place, which is how [`Ctx`] recycles the allocation.
251 #[inline]
252 fn unique(&mut self) -> Option<&mut EnvNode> {
253 #[cfg(feature = "stats-env")]
254 return Rc::get_mut(&mut self.0).map(|t| &mut t.node);
255 #[cfg(not(feature = "stats-env"))]
256 Rc::get_mut(&mut self.0)
257 }
258}
259
260impl core::ops::Deref for EnvRef {
261 type Target = EnvNode;
262
263 #[inline]
264 fn deref(&self) -> &EnvNode {
265 #[cfg(feature = "stats-env")]
266 return &self.0.node;
267 #[cfg(not(feature = "stats-env"))]
268 &self.0
269 }
270}
271
272#[cfg(feature = "stats-env")]
273impl Clone for EnvRef {
274 #[track_caller]
275 #[inline]
276 fn clone(&self) -> Self {
277 let rc = Rc::clone(&self.0);
278 stats_env::cloned(
279 &rc.max,
280 Rc::strong_count(&rc),
281 core::panic::Location::caller(),
282 );
283 Self(rc)
284 }
285}
286
287#[cfg(feature = "stats-env")]
288impl Drop for EnvRef {
289 #[inline]
290 fn drop(&mut self) {
291 // A free cell (peak 0) was counted when it was recycled.
292 if Rc::strong_count(&self.0) == 1 && self.0.max.get() != 0 {
293 // A death under `Ctx::release` is explicit; any other is implicit.
294 if !stats_env::freed(self.0.max.get()) {
295 #[cfg(feature = "stats-env-drops")]
296 stats_env::died();
297 }
298 }
299 }
300}
301
302/// Per-thread environment statistics, built only with the `stats-env` feature:
303/// the [`EnvNode`]s allocated by kind, how often an [`EnvRef`] is cloned, and a
304/// histogram of the highest reference count each node reached before it was freed.
305///
306/// The reference count includes clones the evaluator holds only on the Rust stack
307/// for the length of a call (a block's or match arm's copy of its environment, a
308/// module turned into a temporary `Env`), so a count of 2 or 3 is partly borrow-like.
309/// Nodes still alive when the counts are read are not in the histogram.
310///
311/// Clones are also counted by call site ([`sites`]), passed down with
312/// `#[track_caller]` through [`Env`](super::Env)'s and [`EnvRef`](super::EnvRef)'s
313/// `clone`. A clone made by cloning a [`Value`](crate::Value) that holds an
314/// environment (a closure, a module) stops at `Value`'s derived `Clone`, so all of
315/// those share one site in `value.rs`.
316///
317/// With `stats-env-drops`, deaths are also counted by where they happen
318/// ([`drop_sites`](stats_env::drop_sites)): `Drop` has no caller location, so each
319/// death walks the stack, and the report skips the drop glue to name the function
320/// whose scope ended.
321///
322/// Tracking the maximum adds a word to every node's allocation.
323#[cfg(feature = "stats-env")]
324pub mod stats_env {
325 use super::EnvNode;
326 use core::cell::{Cell, RefCell};
327 use core::panic::Location;
328 use std::collections::BTreeMap;
329 use std::vec::Vec;
330
331 /// Histogram buckets: bucket `0` holds nodes whose count never exceeded 1, and
332 /// bucket `b > 0` those whose maximum was in `2^(b-1) + 1 ..= 2^b`; the last
333 /// bucket takes everything above.
334 pub const BUCKETS: usize = 24;
335
336 /// A snapshot of the counters since the last [`reset`].
337 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
338 pub struct Counts {
339 pub cons: u64,
340 pub frame: u64,
341 pub module: u64,
342 /// [`EnvRef`](super::EnvRef) clones, of any node.
343 pub clones: u64,
344 /// Freed nodes by the highest reference count they reached (see [`BUCKETS`]).
345 pub max_shared: [u64; BUCKETS],
346 /// Of the freed nodes, those that died inside
347 /// [`Ctx::release`](super::Ctx::release); the rest died implicitly.
348 pub released: u64,
349 /// Of the allocated nodes, those placed in a recycled free cell.
350 pub reused: u64,
351 }
352
353 /// A node and the highest reference count it has reached.
354 #[derive(Debug)]
355 pub(super) struct Tracked {
356 pub(super) node: EnvNode,
357 pub(super) max: Cell<u32>,
358 }
359
360 impl Tracked {
361 #[inline]
362 pub(super) fn new(node: EnvNode) -> Self {
363 allocated(&node);
364 Tracked {
365 node,
366 max: Cell::new(1),
367 }
368 }
369
370 /// The node was rewritten as a free cell: it died here (inside
371 /// [`Ctx::release`](super::Ctx::release)), and a peak of 0 marks it free.
372 #[inline]
373 pub(super) fn recycled(&self) {
374 freed(self.max.get());
375 self.max.set(0);
376 }
377
378 /// A free cell now holds `self.node`, allocated without the allocator.
379 #[inline]
380 pub(super) fn reused(&self) {
381 allocated(&self.node);
382 self.max.set(1);
383 STATS.with(|s| s.reused.set(s.reused.get() + 1));
384 }
385 }
386
387 /// Counts a node by kind, whether newly allocated or reusing a free cell.
388 #[inline]
389 fn allocated(node: &EnvNode) {
390 STATS.with(|s| {
391 let kind = match node {
392 EnvNode::Cons { .. } => &s.cons,
393 EnvNode::Frame { .. } => &s.frame,
394 EnvNode::Module { .. } => &s.module,
395 };
396 kind.set(kind.get() + 1);
397 });
398 }
399
400 /// The clones made at one call site.
401 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
402 pub struct Site {
403 pub file: &'static str,
404 pub line: u32,
405 pub column: u32,
406 pub clones: u64,
407 /// Clones that raised their node's highest count: the site that set the
408 /// maximum, as opposed to a copy taken while another reference was already
409 /// counting.
410 pub raised_max: u64,
411 }
412
413 type SiteKey = (&'static str, u32, u32);
414
415 struct Stats {
416 cons: Cell<u64>,
417 frame: Cell<u64>,
418 module: Cell<u64>,
419 clones: Cell<u64>,
420 max_shared: [Cell<u64>; BUCKETS],
421 released: Cell<u64>,
422 reused: Cell<u64>,
423 /// Set while [`Ctx::release`](super::Ctx::release) drops an environment.
424 releasing: Cell<bool>,
425 /// `(clones, raised_max)` by call site.
426 sites: RefCell<BTreeMap<SiteKey, (u64, u64)>>,
427 /// Deaths by the return addresses on the stack when they happened.
428 #[cfg(feature = "stats-env-drops")]
429 deaths: RefCell<drops::Deaths>,
430 }
431
432 std::thread_local! {
433 static STATS: Stats = const {
434 Stats {
435 cons: Cell::new(0),
436 frame: Cell::new(0),
437 module: Cell::new(0),
438 clones: Cell::new(0),
439 max_shared: [const { Cell::new(0) }; BUCKETS],
440 released: Cell::new(0),
441 reused: Cell::new(0),
442 releasing: Cell::new(false),
443 sites: RefCell::new(BTreeMap::new()),
444 #[cfg(feature = "stats-env-drops")]
445 deaths: RefCell::new(drops::Deaths::with_hasher(
446 std::hash::BuildHasherDefault::new(),
447 )),
448 }
449 };
450 }
451
452 /// A clone at `at` brought the count to `count`.
453 #[inline]
454 pub(super) fn cloned(max: &Cell<u32>, count: usize, at: &'static Location<'static>) {
455 let count = u32::try_from(count).unwrap_or(u32::MAX);
456 let raised = count > max.get();
457 if raised {
458 max.set(count);
459 }
460 STATS.with(|s| {
461 s.clones.set(s.clones.get() + 1);
462 let mut sites = s.sites.borrow_mut();
463 let site = sites
464 .entry((at.file(), at.line(), at.column()))
465 .or_default();
466 site.0 += 1;
467 site.1 += u64::from(raised);
468 });
469 }
470
471 /// The last reference to a node whose count peaked at `max` is going away.
472 /// Returns whether it dies inside [`Ctx::release`](super::Ctx::release).
473 #[inline]
474 pub(super) fn freed(max: u32) -> bool {
475 let bucket = ((u32::BITS - (max.max(1) - 1).leading_zeros()) as usize).min(BUCKETS - 1);
476 STATS.with(|s| {
477 let slot = &s.max_shared[bucket];
478 slot.set(slot.get() + 1);
479 let releasing = s.releasing.get();
480 if releasing {
481 s.released.set(s.released.get() + 1);
482 }
483 releasing
484 })
485 }
486
487 /// Runs `f` with every node death counted as released, cascades included.
488 #[inline]
489 pub(super) fn releasing(f: impl FnOnce()) {
490 STATS.with(|s| s.releasing.set(true));
491 f();
492 STATS.with(|s| s.releasing.set(false));
493 }
494
495 /// This thread's counts so far.
496 pub fn get() -> Counts {
497 STATS.with(|s| Counts {
498 cons: s.cons.get(),
499 frame: s.frame.get(),
500 module: s.module.get(),
501 clones: s.clones.get(),
502 max_shared: core::array::from_fn(|b| s.max_shared[b].get()),
503 released: s.released.get(),
504 reused: s.reused.get(),
505 })
506 }
507
508 /// This thread's clones by call site, most clones first.
509 pub fn sites() -> Vec<Site> {
510 let mut out: Vec<Site> = STATS.with(|s| {
511 s.sites
512 .borrow()
513 .iter()
514 .map(|(&(file, line, column), &(clones, raised_max))| Site {
515 file,
516 line,
517 column,
518 clones,
519 raised_max,
520 })
521 .collect()
522 });
523 out.sort_by_key(|s| core::cmp::Reverse(s.clones));
524 out
525 }
526
527 /// Zeroes this thread's counts, per-site ones included, and returns what the
528 /// totals were.
529 pub fn reset() -> Counts {
530 let counts = get();
531 STATS.with(|s| {
532 for c in [
533 &s.cons,
534 &s.frame,
535 &s.module,
536 &s.clones,
537 &s.released,
538 &s.reused,
539 ] {
540 c.set(0);
541 }
542 for c in &s.max_shared {
543 c.set(0);
544 }
545 s.sites.borrow_mut().clear();
546 #[cfg(feature = "stats-env-drops")]
547 s.deaths.borrow_mut().clear();
548 });
549 counts
550 }
551
552 /// The last reference to a node is going away implicitly: record the stack.
553 #[cfg(feature = "stats-env-drops")]
554 #[inline(never)]
555 pub(super) fn died() {
556 let stack = drops::capture();
557 STATS.with(|s| *s.deaths.borrow_mut().entry(stack).or_default() += 1);
558 }
559
560 /// Where nodes died since the last [`reset`], most deaths first.
561 #[cfg(feature = "stats-env-drops")]
562 pub fn drop_sites() -> Vec<drops::DropSite> {
563 STATS.with(|s| drops::summarize(&s.deaths.borrow()))
564 }
565
566 /// Stack capture and its symbolization for [`drop_sites`].
567 #[cfg(feature = "stats-env-drops")]
568 pub mod drops {
569 use core::cell::RefCell;
570 use std::collections::HashMap;
571 use std::hash::{BuildHasherDefault, DefaultHasher};
572 use std::string::{String, ToString};
573 use std::vec::Vec;
574
575 /// Frames kept per death: enough to climb out of the drop glue of a
576 /// node that dies because its parent did, a few levels deep.
577 const DEPTH: usize = 32;
578
579 /// Return addresses, innermost first, zero-padded.
580 pub(in super::super) type Stack = [usize; DEPTH];
581
582 /// Deaths by stack; a fixed hasher so the map can start in a `const`.
583 pub(in super::super) type Deaths = HashMap<Stack, u64, BuildHasherDefault<DefaultHasher>>;
584
585 /// What was being destroyed when the node died, innermost first.
586 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
587 pub enum Via {
588 /// The handle itself went out of scope or was overwritten.
589 Direct,
590 /// A [`Value`](crate::Value) holding it (a closure, a module, a list
591 /// of those) was destroyed.
592 Value,
593 /// An [`EnvNode`](crate::EnvNode) holding it died first: the cascade
594 /// down a chain.
595 Node,
596 }
597
598 /// Nodes that died at one place.
599 #[derive(Debug, Clone, PartialEq, Eq)]
600 pub struct DropSite {
601 /// `function (file:line)` of the first frame outside drop glue, or
602 /// `?` when every captured frame was glue.
603 pub site: String,
604 pub via: Via,
605 pub deaths: u64,
606 }
607
608 /// Walks the frame-pointer chain, which the Apple arm64 ABI mandates:
609 /// two loads per frame, where unwinding tables cost tens of microseconds
610 /// a death.
611 #[cfg(all(target_arch = "aarch64", target_vendor = "apple"))]
612 #[inline(never)]
613 pub(in super::super) fn capture() -> Stack {
614 let mut stack = [0; DEPTH];
615 let mut fp: usize;
616 // SAFETY: reads this frame's own frame pointer.
617 unsafe { core::arch::asm!("mov {}, x29", out(reg) fp) };
618 for slot in &mut stack {
619 if fp == 0 || !fp.is_multiple_of(16) {
620 break;
621 }
622 // SAFETY: a frame record is `[previous fp, return address]`, and
623 // the chain ends at a null or non-ascending pointer.
624 let (next, ret) = unsafe { (*(fp as *const usize), *((fp + 8) as *const usize)) };
625 *slot = ret;
626 if next <= fp {
627 break;
628 }
629 fp = next;
630 }
631 stack
632 }
633
634 #[cfg(not(all(target_arch = "aarch64", target_vendor = "apple")))]
635 pub(in super::super) fn capture() -> Stack {
636 let mut stack = [0; DEPTH];
637 let mut i = 0;
638 // SAFETY: the stats are per thread and the capture does not unwind.
639 unsafe {
640 backtrace::trace_unsynchronized(|frame| {
641 stack[i] = frame.ip() as usize;
642 i += 1;
643 i < DEPTH
644 });
645 }
646 stack
647 }
648
649 /// One symbol at an address; an inlined call contributes several,
650 /// innermost first.
651 #[derive(Clone)]
652 struct Sym {
653 name: String,
654 at: String,
655 }
656
657 std::thread_local! {
658 /// Symbolization is slow and addresses repeat across benches.
659 static RESOLVED: RefCell<HashMap<usize, Vec<Sym>>> = RefCell::new(HashMap::new());
660 }
661
662 fn resolve(ip: usize) -> Vec<Sym> {
663 RESOLVED.with(|r| {
664 r.borrow_mut()
665 .entry(ip)
666 .or_insert_with(|| {
667 let mut syms = Vec::new();
668 // A return address points past the call; step back into it.
669 let pc = ip.saturating_sub(1) as *mut core::ffi::c_void;
670 backtrace::resolve(pc, |sym| {
671 let name = sym
672 .name()
673 .map_or_else(|| "?".to_string(), |n| std::format!("{n:#}"));
674 let file = sym
675 .filename()
676 .map(|f| f.to_string_lossy().into_owned())
677 .unwrap_or_default();
678 let file = file.find("src/").map_or(&file[..], |i| &file[i..]);
679 let at = match sym.lineno() {
680 Some(line) => std::format!("{file}:{line}"),
681 None => file.to_string(),
682 };
683 syms.push(Sym { name, at });
684 });
685 syms
686 })
687 .clone()
688 })
689 }
690
691 /// The compiler's drop glue for a type (`drop_glue` in newer toolchains).
692 fn is_drop_glue(name: &str) -> bool {
693 name.contains("drop_in_place") || name.contains("drop_glue")
694 }
695
696 /// Frames that only carry the destruction along: drop glue, `Drop`
697 /// impls, `mem::drop`, and this instrumentation.
698 fn is_glue(name: &str) -> bool {
699 is_drop_glue(name)
700 || name.contains("as core::ops::drop::Drop>::drop")
701 || name.contains("drop_slow")
702 || name.starts_with("core::mem::drop")
703 || name.contains("stats_env")
704 || name.contains("backtrace")
705 }
706
707 /// The container a drop-glue frame is destroying, if it is one of ours.
708 fn container(name: &str) -> Option<Via> {
709 if !is_drop_glue(name) {
710 None
711 } else if name.contains("eval_env::EnvNode") || name.contains("stats_env::Tracked") {
712 Some(Via::Node)
713 } else if name.contains("value::Value") {
714 Some(Via::Value)
715 } else {
716 None
717 }
718 }
719
720 fn classify(stack: &Stack) -> (String, Via) {
721 let mut via = None;
722 for &ip in stack.iter().take_while(|&&ip| ip != 0) {
723 for sym in resolve(ip) {
724 if is_glue(&sym.name) || sym.at.contains("/backtrace-") {
725 via = via.or_else(|| container(&sym.name));
726 continue;
727 }
728 return (
729 std::format!("{} ({})", sym.name, sym.at),
730 via.unwrap_or(Via::Direct),
731 );
732 }
733 }
734 ("?".to_string(), via.unwrap_or(Via::Direct))
735 }
736
737 pub(in super::super) fn summarize(deaths: &Deaths) -> Vec<DropSite> {
738 let mut by_site: HashMap<(String, Via), u64> = HashMap::new();
739 for (stack, &n) in deaths {
740 *by_site.entry(classify(stack)).or_default() += n;
741 }
742 let mut out: Vec<DropSite> = by_site
743 .into_iter()
744 .map(|((site, via), deaths)| DropSite { site, via, deaths })
745 .collect();
746 out.sort_by(|a, b| b.deaths.cmp(&a.deaths).then_with(|| a.site.cmp(&b.site)));
747 out
748 }
749 }
750}
751
752/// One link of the environment list: a single binding carrying its `val` inline,
753/// or the frame that terminates the walk. The empty environment is `None`.
754/// [`Env`] is an `Option<EnvRef>`, utilizing the null-pointer niche to remain
755/// one word wide, avoiding allocation and refcount traffic when ending a chain.
756/// Lambda activations cons one `Cons` per bound name (zero for binder-less heads);
757/// partial application environments share the consed prefix via `Rc`.
758/// References are resolved to de Bruijn indices against this list's shape.
759#[derive(Debug)]
760pub enum EnvNode {
761 Cons {
762 val: Value,
763 next: Env,
764 },
765 /// Module instance terminal: carries the item's module instead of the
766 /// empty terminal, allowing [`Expr::ModItem`] sibling references to resolve
767 /// by walking out (see [`crate::eval::eval_env`]). It binds no name and is not
768 /// counted by de Bruijn `Local` indices. Materialized closures capture an
769 /// environment ending in this node to keep the module alive.
770 ///
771 /// This node is the module instance; [`Value::Module`] is a handle to it,
772 /// created by `instantiate`. `M.name` runs the body in this environment.
773 ///
774 /// `id_base` is the module's reserved ID block: item `i` stamps its closure
775 /// with `id_base + i`, ensuring `M.f == M.f` (see `materialize_body`).
776 ///
777 /// `frame` is the environment the module was instantiated against (imports,
778 /// host prelude, and identities), with slot order fixed by
779 /// [`ModuleData::frame_names`]. De Bruijn indices walk through this node into
780 /// `frame` without counting the node itself. The frame can be a flat
781 /// [`Frame`](EnvNode::Frame) or a cons chain (`recur`).
782 ///
783 /// This structure ensures temporal acyclicity: a frame holds only values
784 /// existing before the frame, and immutability prevents them from pointing
785 /// back to the frame.
786 Module {
787 data: Rc<ModuleData>,
788 id_base: u64,
789 frame: Env,
790 },
791 /// A closure's captured frame: values of free variables used by the body,
792 /// copied at creation in the order fixed by `Lambda::captures`. This terminates
793 /// the de Bruijn walk: an index beyond the consed cells selects `vals[remaining]`
794 /// (see `lookup`), allowing references to enclosing scopes to avoid walking
795 /// the entire program.
796 ///
797 /// `home` stores the environment terminal (`None` or the [`Module`](EnvNode::Module)
798 /// node) beyond the frame, so `ModItem` references in the body can still find
799 /// the module without new allocations.
800 ///
801 /// Values are stored inline behind the environment's `Rc`, making the frame
802 /// a single allocation.
803 Frame {
804 vals: Box<[Value]>,
805 home: Env,
806 },
807}
808
809impl EnvNode {}
810
811/// Evaluation context threaded through the tree-walker: includes the unique-id
812/// source for closure identities and the builtin module cache. It does not hold
813/// a module registry or loader; imports are resolved before evaluation
814/// ([`crate::load_module`]), and loaded modules are `Rc`-held values.
815pub struct Ctx {
816 ids: Cell<u64>,
817 /// The instantiated [builtin modules](MODULES), one slot each, built on first
818 /// reference. Caching them here is what gives `__Int` an identity: every
819 /// reference in one evaluation yields the *same* module value, so
820 /// `__eq __Int Int` holds. Two different contexts hold non-identical `__Int`s —
821 /// v0's "two loads are unrelated modules" caveat, which a content-addressed
822 /// module brand would retire.
823 modules: [OnceCell<Value>; NMODULES],
824 /// Environment cells recycled by [`release`](Self::release), linked through
825 /// their own `next`, and how many there are (at most [`FREE_CAP`]).
826 free: Cell<Env>,
827 free_len: Cell<u32>,
828}
829
830/// The most free cells a [`Ctx`] keeps. Beyond it, released nodes go back to the
831/// allocator; the cap bounds what an evaluation holds on to after a deep
832/// recursion unwinds.
833const FREE_CAP: u32 = 4096;
834
835impl Ctx {
836 /// A context with a fresh id source. Used by [`crate::eval::eval`] and [`crate::eval::apply`].
837 pub fn new() -> Self {
838 Ctx {
839 ids: Cell::new(0),
840 modules: core::array::from_fn(|_| OnceCell::new()),
841 free: Cell::new(Env::EMPTY),
842 free_len: Cell::new(0),
843 }
844 }
845
846 /// A context resuming an id source at `ids` so identities continue from
847 /// an earlier context. This is required when a host builds a fresh `Ctx` per
848 /// call but shares values between them (e.g., Python bindings). Without this,
849 /// a module's reserved id block and subsequent closure IDs could alias,
850 /// causing `__eq` to incorrectly identify them as equal. The counter is
851 /// carried by value to avoid a pointer chase during closure minting.
852 pub fn with_ids(ids: u64) -> Self {
853 Ctx {
854 ids: Cell::new(ids),
855 modules: core::array::from_fn(|_| OnceCell::new()),
856 free: Cell::new(Env::EMPTY),
857 free_len: Cell::new(0),
858 }
859 }
860
861 /// Returns the current id source value to seed a successor context
862 /// (see [`with_ids`](Self::with_ids)).
863 pub fn ids_used(&self) -> u64 {
864 self.ids.get()
865 }
866
867 /// Mints the next unique id for a closure's identity, used for equality
868 /// and ordering.
869 pub(crate) fn next_id(&self) -> u64 {
870 let n = self.ids.get();
871 self.ids.set(n + 1);
872 n
873 }
874
875 /// Hands back an environment whose scope ends here. The hot scope ends of
876 /// activation environments (a closure body's, a block's) call this instead of
877 /// dropping implicitly, so that the nodes dying with it become free cells for
878 /// [`alloc`](Self::alloc). Other drops stay implicit and go to the allocator.
879 /// See `docs/todo/elly-perf-env-stats.md`.
880 #[inline]
881 pub(crate) fn release(&self, env: Env) {
882 #[cfg(feature = "stats-env")]
883 stats_env::releasing(|| self.release_inner(env));
884 #[cfg(not(feature = "stats-env"))]
885 self.release_inner(env);
886 }
887
888 #[inline]
889 fn release_inner(&self, env: Env) {
890 // A shared node only loses a reference: nothing to recycle.
891 if let Some(node) = env.0 {
892 if node.is_unique() {
893 self.recycle(node);
894 }
895 }
896 }
897
898 /// Walks down from `node` while each node dies with the reference in hand,
899 /// rewriting it in place as a free cell (every variant shares the one `Rc`
900 /// allocation) and following its link: `next`, a frame's `home`, a module's
901 /// `frame`. The rest of a node (its value, a frame's slice, a module's data)
902 /// is dropped normally. Stops at a shared node or a full list, which then
903 /// drops as usual.
904 #[inline(never)]
905 fn recycle(&self, node: EnvRef) {
906 let mut free = self.free.replace(Env::EMPTY);
907 let mut len = self.free_len.get();
908 let mut cur = Some(node);
909 while let Some(mut node) = cur {
910 if len >= FREE_CAP {
911 break;
912 }
913 let Some(slot) = node.unique() else { break };
914 let free_cell = EnvNode::Cons {
915 val: Value::Unit,
916 next: free,
917 };
918 let old = core::mem::replace(slot, free_cell);
919 #[cfg(feature = "stats-env")]
920 node.0.recycled();
921 free = Env(Some(node));
922 len += 1;
923 cur = match old {
924 EnvNode::Cons { val, next } => {
925 drop(val);
926 next.0
927 }
928 EnvNode::Frame { vals, home } => {
929 drop(vals);
930 home.0
931 }
932 EnvNode::Module { data, frame, .. } => {
933 drop(data);
934 frame.0
935 }
936 };
937 }
938 self.free.set(free);
939 self.free_len.set(len);
940 }
941
942 /// A node holding `node`: a free cell rewritten in place if there is one,
943 /// otherwise a new allocation.
944 #[inline]
945 pub(crate) fn alloc(&self, node: EnvNode) -> EnvRef {
946 let Some(mut cell) = self.free.replace(Env::EMPTY).0 else {
947 return EnvRef::new(node);
948 };
949 let slot = cell.unique().expect("a free cell has one reference");
950 let EnvNode::Cons { next, .. } = core::mem::replace(slot, node) else {
951 unreachable!("a free cell is a `Cons`")
952 };
953 self.free.set(next);
954 self.free_len.set(self.free_len.get() - 1);
955 #[cfg(feature = "stats-env")]
956 cell.0.reused();
957 cell
958 }
959
960 /// Reserves a block of `n` consecutive ids and returns the base. Used by
961 /// [`instantiate`] to give module items stable identities, preventing alias
962 /// with individually minted ids.
963 pub(crate) fn reserve_ids(&self, n: u64) -> u64 {
964 let base = self.ids.get();
965 self.ids.set(base + n);
966 base
967 }
968
969 /// Gets a builtin module (`__Int`, `__Map`, etc.), instantiated on first
970 /// reference and cached.
971 ///
972 /// Builtin modules are [`ModuleData`] with [`Expr::Builtin`] bodies. A written
973 /// `__Int.add` is folded to its member at resolve, but this method handles
974 /// remaining dynamic uses of the module as a value.
975 pub(crate) fn builtin_module(&self, op: Builtin) -> Value {
976 let slot = op.module_slot().expect("a builtin module");
977 self.modules[slot]
978 .get_or_init(|| {
979 let items = op.items().expect("a builtin module has items");
980 let bindings = items
981 .iter()
982 .map(|(name, op)| (Text::from_static(name), Expr::Builtin(*op)))
983 .collect();
984 // The module's `const` declarations, name-sorted (the table already
985 // is). Only `__Bool` has any; they populate the iota table an
986 // iota's `<const .name>` rendering reads. The re-exporting `true` /
987 // `false` items above forward to these by way of their arity-0
988 // builtins, so the two spellings land on the same iota.
989 let iotas = op
990 .iotas()
991 .expect("a builtin module has an iota list")
992 .iter()
993 .map(|n| Text::from_static(n))
994 .collect();
995 // A builtin module is named after itself, so `__Mod.name __Int`
996 // answers `"__Int"` — the same question a loaded module answers
997 // with the name its source was resolved under.
998 let data = Rc::new(ModuleData::from_bindings(
999 bindings,
1000 Vec::new(),
1001 iotas,
1002 Box::new([]),
1003 Box::new([]),
1004 // A builtin module is a table of operations and nothing else:
1005 // there is no source to have written a `__main` in.
1006 None,
1007 Some(Text::from_static(op.name())),
1008 ));
1009 eval::instantiate(data, &[], self).expect("a builtin module has no frame")
1010 })
1011 .clone()
1012 }
1013}
1014
1015/// Frees the cells iteratively: dropping the list whole would recurse once per
1016/// cell.
1017impl Drop for Ctx {
1018 fn drop(&mut self) {
1019 let mut cur = self.free.replace(Env::EMPTY).0;
1020 while let Some(mut cell) = cur {
1021 cur = match cell.unique() {
1022 Some(EnvNode::Cons { next, .. }) => core::mem::replace(next, Env::EMPTY).0,
1023 _ => None,
1024 };
1025 }
1026 }
1027}
1028
1029impl Default for Ctx {
1030 fn default() -> Self {
1031 Self::new()
1032 }
1033}
1034
1035/// Performs a single walk to retrieve both the value at `index` and the
1036/// environment terminal. Resuming from the landing cell avoids re-walking links.
1037///
1038/// If the walk passes a [`Module`](EnvNode::Module) node, that node is returned
1039/// as the home, ensuring the resulting environment doesn't unexpectedly shift to
1040/// the module's own frame terminal.
1041fn lookup_and_home(env: &Env, index: u32) -> (Value, Env) {
1042 let mut cur = env;
1043 let mut rest = index as usize;
1044 let mut passed: Option<&Env> = None;
1045 let val = loop {
1046 match cur
1047 .0
1048 .as_deref()
1049 .expect("resolved index walked past the environment")
1050 {
1051 EnvNode::Cons { val, next } => {
1052 if rest == 0 {
1053 break val.clone();
1054 }
1055 rest -= 1;
1056 cur = next;
1057 }
1058 EnvNode::Frame { vals, .. } => match vals.get(rest) {
1059 Some(val) => break val.clone(),
1060 None => unreachable!("resolved index walked past the captured frame"),
1061 },
1062 EnvNode::Module { frame, .. } => {
1063 if passed.is_none() {
1064 passed = Some(cur);
1065 }
1066 cur = frame;
1067 }
1068 }
1069 };
1070 let home = match passed {
1071 Some(home) => home.clone(),
1072 None => cur.find_home(),
1073 };
1074 (val, home)
1075}
1076
1077/// Builds a closure's captured frame from its resolved `plan` in one walk of
1078/// the defining environment. The plan is sorted by `outer`, so the walk only
1079/// moves forward, advancing to each capture's cell and writing the value to the
1080/// slot. Captures reaching the enclosing [`Frame`](EnvNode::Frame) use direct
1081/// indexing (`outer - depth`).
1082///
1083/// The frame is filled out of order (slot order is fixed at first use). The walk
1084/// also retrieves the chain's terminal for the frame's `home`, remembering any
1085/// [`Module`](EnvNode::Module) nodes passed.
1086fn build_frame_and_home(env: &Env, plan: &[Capture]) -> (Box<[Value]>, Env) {
1087 let mut vals: Vec<Value> = alloc::vec![Value::I64(0); plan.len()];
1088 let mut cur = env;
1089 let mut depth = 0u32;
1090 let mut passed: Option<&Env> = None;
1091 for cap in plan {
1092 loop {
1093 match cur
1094 .0
1095 .as_deref()
1096 .expect("capture index walked past the environment")
1097 {
1098 EnvNode::Cons { val, next } => {
1099 if depth == cap.outer {
1100 vals[cap.slot as usize] = val.clone();
1101 break;
1102 }
1103 depth += 1;
1104 cur = next;
1105 }
1106 EnvNode::Frame { vals: outer, .. } => {
1107 match outer.get((cap.outer - depth) as usize) {
1108 Some(val) => vals[cap.slot as usize] = val.clone(),
1109 None => unreachable!("capture index walked past the captured frame"),
1110 }
1111 break;
1112 }
1113 // The terminal binds no name, so it is crossed without advancing
1114 // `depth`: a capture of a module's frame value counts only the
1115 // lexical cells between it and the reference.
1116 EnvNode::Module { frame, .. } => {
1117 if passed.is_none() {
1118 passed = Some(cur);
1119 }
1120 cur = frame;
1121 }
1122 }
1123 }
1124 }
1125 let home = match passed {
1126 Some(home) => home.clone(),
1127 None => cur.find_home(),
1128 };
1129 (vals.into_boxed_slice(), home)
1130}