← board

A set is 32 bytes whatever its bounds

PARKED 2026-09-02 BY THE OWNER. "let's park it for now. i'd say, for now, our sets are just always 32 byte. and advise against using records with sets for file-io or document it."

rainy-day, not rejected and not low-prio — the measurement is true, the improvement is real, and it is a future plan rather than a non-goal. Narrowing would buy three things at once (FPC-identical SizeOf, blittable records for file of T, and ESP memory) for one ABI change; the owner judged the overhead not worth it now, having seen all three. The decision and every measurement behind it are in [[decide-a-what-a-set-costs-bits-bytes-bounds-and-what-file-of-t-writes-to-disk]].

THE MECHANISM IS ALREADY DESIGNED, and it is cheaper than the estimate this was parked on. The owner's smallset proposal — a hidden second type kind, same set keyword in source — avoids variable-width sets entirely, because a small set is a DWORD riding existing scalar machinery. Measured: of 115 tySet sites, 54 are bare kind tests (mechanical) and only 9 are size or copy decisions. Design it with ONE IsSetKind() predicate, never 54 two-armed comparisons. Full write-up in the decide.

Reopen it with: a real program that the 32-byte width makes infeasible — an ESP target that runs out of RAM on set-heavy code, or a record-file format that must blit. Not a SizeOf probe: under the 2026-09-02 rule SizeOf(set of 0..7) = 32 is a true statement about our representation.

Why this is its own ticket

It arrived as third (2) of compat-pascal-four-type-sizes-…, beside the string[N] third. frankb-a9 asked for the split after fixing the string half and the reason is ranking, not tidiness: bundled, the set half inherits a priority the string half earned and reads as comparably tractable to whoever picks it up. It is not. The string half was a matter of asking the right sizing function; this one changes an ABI class.

Measured

set of 0..7 — pxx 32, FPC 4. FPC's rule is the small-set word: 4 bytes when the high bound is ≤ 31, 32 above it, and it does not rebase to lo — so set of 200..207 is 32 in both compilers and only the low-bounded case diverges. That is the common case; set of Char and set of 0..255 are already agreed.

FPC's rule measured first-hand 2026-09-02 (3.2.2, -O-): the width is a function of the HIGH BOUND ALONE — 4 if hi <= 31, else 32. No rebasing, no span term: set of 32..63 spans exactly 32 values and still costs 32 bytes, and set of 'x'..'z' costs 32 for three bits. This confirms frankb-a9's no-rebase measurement and is now independently sourced.

Corroborated 2026-09-02 by two sources that fail differently — frankb-a9's measurement against FPC, and a grep of our own definitions:

compiler/defs.inc:1097:  IR_SET_COPY = 40;  { ... copy full 32-byte set }
compiler/defs.inc:2003:    tySet,           { 21: Set — 32-byte bitset }

The width is not in a table someone can widen. It is in the type kind's own definition and in an IR opcode's documented contract. 115 tySet sites; 39 IR_SET_COPY/IR_SET_LIT sites.

What makes it hard, and what does not

Not hard: no value is wrong. Every set operation produces the right answer at 32 bytes, on every target, which is exactly why nothing has caught it — a differential probe on set behaviour passes.

Hard: the size is part of the by-value ABI class. A set narrower than 32 bytes is passed and returned differently, so this is not a front-end mapping change. IR_SET_COPY and IR_SET_LIT both carry the constant in their contract and every backend implements them.

Relation to the umbrella

Wired to [[umbrella-sizeof-is-one-answer]] as the one member where the size oracle is not the defect — TypeSlotSize(tySet) is honest about what pxx builds. This is the case where the layout itself is the thing to change, which is worth having inside the umbrella precisely because it is the opposite shape: fixing the oracles cannot touch it, and it would otherwise look done when they are.

Prio

prio: 30 is its own intrinsic worth — a real efficiency defect, no wrong value, no program blocked. It inherits p75 from the umbrella, which is the number that should route it.

Built and banked, 2026-09-02 (frankA) — the park stands, the work is on disk

The owner's smallset design above was implemented while this ticket was being parked; the two crossed in flight. It is not landed — a park is a live decision and this is a fork of intent, so the tree is back at 32 bytes and the change sits as a patch:

devdocs/dev/parked-patches/smallset-4-byte-set-storage-class.patch   # 22 files, applies clean at ff62bb870

Measured at ff62bb870 with the patch applied (converged after 1 round(s), binary d374f4a8bdd7): the new test/test_small_set_width.pas matches the FPC 3.2.2 oracle byte-for-byte natively and on i386, aarch64, arm32 and riscv32; a 57-test set corpus gives 50 same / 1 diff (test_rtti InstanceSize 80 → 48, the intended win) / 0 new failures; 32/32 cross rows SAME; gate.sh quick GREEN with the FPC seed canary PASS. Positive controls both ways: test_set_subrange binaries DIFFER on all four targets, test_sets and test_const_set are byte-identical on all four, and the new test FAILS against the pre-change compiler. Whether that changes the park is the owner's call — [[decide-a-the-smallset-mechanism-is-built-and-green-does-that-change-the-park]] has the full table and the counter-argument.

One measurement worth keeping whatever is decided

FPC's Include/Exclude with a variable element is two steps, not one: fold the element to a BYTE (and 255), then skip the write if that byte index is outside this set's storage. Measured on the raw bytes of the set object, same at -O2 and -O-: on set of 0..7, Include(s, 100) is a no-op while Include(s, 20) does set bit 20; on set of 0..255, Include(s, 300) sets bit 44. So the boundary is the STORAGE width, not the declared high bound, and pxx already agrees with FPC on every row that a 32-byte set can reach.

It stops agreeing the moment a set is 4 bytes: the naive narrowing — and elemMaskfolds an out-of-storage element onto a valid bit (Include(s, 100) sets bit 4, since 100 and 31 = 4) and looks correct in every existing test, because no current test can distinguish it. A future attempt at this ticket that does not carry a range GUARD will ship that silently.