Skip to main content

praxis_runtime/
small_int.rs

1//! The interned small-`Int` range (§4.3).
2//!
3//! §4.3's uniform object model is normative "even if later optimizations intern
4//! small integers, use tagged pointers, or eliminate allocations through escape
5//! analysis" — provided such an optimization "preserves reference and aliasing
6//! semantics". For `Int` there are none to preserve, and the language already
7//! ships the existence proof: `Unit` and `Bool` are interned singletons, so
8//! every `true` in every program is one object
9//! ([`crate::immortal::Immortals`]).
10//!
11//! **Why sharing an `Int` is unobservable.** There is no identity operator in
12//! the language — `praxis_hir`'s `BinOp` is arithmetic, comparison and the two
13//! logical connectives, and nothing else. `==` on `Int` lowers to `Inst::IntCmp`
14//! over extracted payloads; the structural fallback `praxis_struct_eq` has no
15//! pointer fast path either. `Map`/`Set`/`Counter` keys go through
16//! [`DynamicKey`](crate::dynamic_key::DynamicKey), whose `eq` *does* open with a
17//! pointer comparison — but that is a fast path **for** structural equality, and
18//! `int_equals` is reflexive, so sharing an object can only make it fire more
19//! often, never change the answer. And an `Int` payload is never written after
20//! its allocation: `Inst::StoreScalar` has no builder site and the backend's arm
21//! for it is a documented no-op.
22//!
23//! **Why not `Float` or `Text`.** `Float` fails the reflexivity argument that
24//! carries `DynamicKey`'s fast path — `float_equals` is IEEE, so NaN ≠ NaN — and
25//! interning it would make two separately-written NaN literals compare equal as
26//! map keys. `Text` fails a different test: `TextPayload::Owned(OwnedText)` is
27//! not `Copy`, and [`Heap::alloc_immortal`](crate::Heap) requires `Copy`
28//! *because* an immortal is invisible to `Heap`'s `Drop` — an immortal `Text`
29//! would leak its `Box<str>` at teardown.
30//!
31//! `Char` passes every leg of the argument above, and [`crate::small_char`] is
32//! the second interned scalar range (ADR-107): `char_equals` is a reflexive
33//! `u32 ==`, a `CharPayload` is `Copy`, and ASCII is a bounded set. It is a
34//! separate module rather than a second constant here because the two ranges
35//! have different consumers — this one is read by three crates and by generated
36//! code, and that one only by the runtime.
37//!
38//! **This module is the one statement of the range.** `praxis-mir` asks
39//! [`index_of`] whether a literal is in range at compile time and `praxis-runtime`
40//! asks it again at run time; the Cranelift backend derives the element offset
41//! from the same [`SMALL_INT_MIN`]. A second spelling of the bounds anywhere
42//! would let the compiler emit a table read for a value the table does not hold.
43
44use crate::GcRef;
45use crate::heap::InlineInternSite;
46
47/// The lowest `Int` the runtime interns.
48///
49/// Negative values are worth a bucket because a Praxis program's negative
50/// integers are overwhelmingly small: `-1` as a "not found" sentinel, the four
51/// neighbour offsets a grid walk steps by, an accumulator's initial `-1`.
52pub const SMALL_INT_MIN: i64 = -256;
53
54/// The highest `Int` the runtime interns.
55///
56/// Chosen against the benchmark suite (`benchmarks/praxis/`): it covers every
57/// literal the suite contains, the digits and small constants AoC-shaped input
58/// parsing produces, and the loop counters and collection lengths of a program
59/// whose working set is a few hundred elements. It is deliberately *not* sized
60/// to cover a program's whole data — a `Counter` over a million-line input will
61/// leave the range immediately, and that is the case the allocator is for.
62///
63/// The cost of raising it is [`SMALL_INT_COUNT`] × 24 bytes of permanently
64/// resident pages (a `GcHeader` is 16 bytes since ADR-109 and an `IntPayload`
65/// is 8, and 24 is a rung of the size-class ladder exactly), so `-256..=1024`
66/// is ~30 KiB. Anyone tuning this should re-run the suite rather than reason
67/// about it: the table is free only while it stays in cache — and that 24 is
68/// why ADR-109 paid here as well as on the allocation path, since the table is
69/// resident for the whole process.
70pub const SMALL_INT_MAX: i64 = 1024;
71
72/// How many `Int`s the table holds — the length of
73/// [`Immortals::small_ints`](crate::Immortals) and the bound every index derived
74/// from [`index_of`] respects.
75pub const SMALL_INT_COUNT: usize = (SMALL_INT_MAX - SMALL_INT_MIN + 1) as usize;
76
77/// The size of one table element, for the backend's element-offset arithmetic.
78///
79/// The Cranelift `Inst::ConstGc` lowering indexes the table with a compile-time
80/// constant byte offset, so it needs the stride. Reading it from here rather
81/// than writing `8` there means the stride and the array it indexes are one
82/// statement — the same reason [`index_of`] is the only in-range test.
83pub const SMALL_INT_STRIDE: usize = std::mem::size_of::<GcRef>();
84
85/// `v`'s index in the interned table, or `None` if `v` is outside the range.
86///
87/// A `const fn` so `praxis-mir` can ask it while lowering a literal and the
88/// runtime can ask it on the allocation path, and both get the same answer by
89/// construction. Returning an `Option<usize>` rather than a bool-plus-arithmetic
90/// pair is what keeps "in range" and "which slot" from being two decisions: a
91/// caller that has the index has already proved the value was in range.
92#[inline]
93#[must_use]
94pub const fn index_of(v: i64) -> Option<usize> {
95    if v >= SMALL_INT_MIN && v <= SMALL_INT_MAX {
96        // Cannot overflow or go negative: the branch above bounds `v` on both
97        // sides, and the difference is at most `SMALL_INT_COUNT - 1`.
98        Some((v - SMALL_INT_MIN) as usize)
99    } else {
100        None
101    }
102}
103
104/// Everything the Cranelift backend bakes in to answer an in-range `Int`
105/// inline, as one value (ADR-113).
106///
107/// **This is the only [`InlineInternSite`] in the workspace, and that is the
108/// mechanism.** `InlineInternSite::new` is `pub(crate)`, so the backend cannot
109/// assemble a site of its own; it can only name one this crate minted, and there
110/// is one, here, beside the bounds it describes. A future inline `Char` probe
111/// mints its own next to [`crate::small_char`]'s range — which is what stops it
112/// from being written as a copy of the `Int` arm reading `small_chars` with
113/// `SMALL_INT_MIN`/`SMALL_INT_MAX`, a probe past the end of a table whose only
114/// bound is its length.
115///
116/// The site also carries the pacing predicate's two offsets, which `new` fills
117/// from `Heap` rather than taking as arguments: permission to read this table
118/// inline and the obligation to test [`Heap::collection_is_due`] first are one
119/// value, because they are one decision (ADR-113 decision 1).
120pub const INLINE_INTERN_SITE: InlineInternSite = InlineInternSite::new(
121    core::mem::offset_of!(crate::RuntimeContext, small_ints),
122    SMALL_INT_MIN,
123    SMALL_INT_MAX,
124    SMALL_INT_STRIDE,
125);
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn index_of_covers_exactly_the_declared_range() {
133        // The four boundary cases, which are what a table read gets wrong: one
134        // below the floor, the floor, the ceiling, and one above it.
135        assert_eq!(index_of(SMALL_INT_MIN - 1), None);
136        assert_eq!(index_of(SMALL_INT_MIN), Some(0));
137        assert_eq!(index_of(SMALL_INT_MAX), Some(SMALL_INT_COUNT - 1));
138        assert_eq!(index_of(SMALL_INT_MAX + 1), None);
139        assert_eq!(index_of(0), Some((-SMALL_INT_MIN) as usize));
140        // The extremes of the type, so a future range change cannot make the
141        // subtraction in `index_of` overflow silently.
142        assert_eq!(index_of(i64::MIN), None);
143        assert_eq!(index_of(i64::MAX), None);
144    }
145
146    #[test]
147    fn every_index_is_within_the_table() {
148        // The bound `Immortals::small_int` relies on: `index_of` never answers
149        // an index the table does not have.
150        for v in SMALL_INT_MIN..=SMALL_INT_MAX {
151            let i = index_of(v).expect("in range by construction");
152            assert!(i < SMALL_INT_COUNT);
153        }
154    }
155
156    /// The single unsigned compare generated code emits answers [`index_of`],
157    /// for every `i64` that matters — and hands back the same index.
158    ///
159    /// Generated code cannot afford `index_of`'s two signed compares and two
160    /// branches on the hot path, so it emits the two's-complement identity
161    /// instead: `(v - MIN) as u64 <= (MAX - MIN) as u64` iff `MIN <= v <= MAX`,
162    /// with the subtraction wrapping. The identity is exact and standard, and it
163    /// is also the kind of thing that is *believed* rather than checked until a
164    /// range change makes it false — so it is checked here, in the module that
165    /// owns the range, against the function that is the range's one statement.
166    ///
167    /// The extremes of the type are the cases that fail if the wrap is not
168    /// deliberate: `i64::MAX - (-256)` overflows a signed subtract, and the
169    /// unsigned result must land *above* the span rather than wrapping back into
170    /// it.
171    #[test]
172    fn the_unsigned_range_test_generated_code_emits_answers_index_of() {
173        let span = INLINE_INTERN_SITE.span();
174        assert_eq!(
175            span,
176            (SMALL_INT_MAX - SMALL_INT_MIN) as u64,
177            "the site's span is the range's width"
178        );
179
180        let inline = |v: i64| {
181            let biased = v.wrapping_sub(INLINE_INTERN_SITE.min()) as u64;
182            (biased <= span).then_some(biased as usize)
183        };
184
185        for v in [
186            i64::MIN,
187            i64::MIN + 1,
188            SMALL_INT_MIN - 1,
189            SMALL_INT_MIN,
190            SMALL_INT_MIN + 1,
191            -1,
192            0,
193            1,
194            SMALL_INT_MAX - 1,
195            SMALL_INT_MAX,
196            SMALL_INT_MAX + 1,
197            i64::MAX - 1,
198            i64::MAX,
199        ] {
200            assert_eq!(
201                inline(v),
202                index_of(v),
203                "the inline range test and `index_of` disagree about {v}"
204            );
205        }
206        // And densely across the range and a margin either side of it, because
207        // the boundary list above cannot see an off-by-one in the middle.
208        for v in (SMALL_INT_MIN - 64)..=(SMALL_INT_MAX + 64) {
209            assert_eq!(inline(v), index_of(v), "at {v}");
210        }
211    }
212
213    /// The site's immediates are the table's own, not a second spelling of them.
214    ///
215    /// `the_inline_check_proves_exactly_what_the_wrapper_would` for the
216    /// allocation path: the fast path and `int_ref` must hold one notion of
217    /// which slot holds which value, or a program reads the wrong `Int` — a
218    /// wrong *answer*, silently, which is the only place in ADR-113 that failure
219    /// mode appears.
220    #[test]
221    fn the_inline_sites_immediates_are_the_tables_own() {
222        assert_eq!(INLINE_INTERN_SITE.min(), SMALL_INT_MIN);
223        assert_eq!(
224            INLINE_INTERN_SITE.span() as usize + 1,
225            SMALL_INT_COUNT,
226            "a span is one less than a count, and the table is dense"
227        );
228        assert_eq!(
229            1usize << INLINE_INTERN_SITE.stride_shift(),
230            SMALL_INT_STRIDE,
231            "the shift generated code scales an index by is the table's stride"
232        );
233        assert_eq!(
234            INLINE_INTERN_SITE.table_offset(),
235            core::mem::offset_of!(crate::RuntimeContext, small_ints),
236            "the site names the context field `Immortals::small_ints` is parked in"
237        );
238    }
239
240    #[test]
241    fn the_range_is_dense_and_ordered() {
242        // The table is indexed by `v - SMALL_INT_MIN`, so consecutive values
243        // must map to consecutive slots — the property the backend's
244        // compile-time byte offset depends on.
245        let mut expected = 0;
246        for v in SMALL_INT_MIN..=SMALL_INT_MAX {
247            assert_eq!(index_of(v), Some(expected));
248            expected += 1;
249        }
250        assert_eq!(expected, SMALL_INT_COUNT);
251    }
252}