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}