Skip to content

Keep the boot image under Chez's LZ4 fasl ceiling, and add --boot fast|small|plain - #889

Merged
yogthos merged 2 commits into
mainfrom
fix/vfasl-boot-lz4-ceiling
Sep 8, 2026
Merged

Keep the boot image under Chez's LZ4 fasl ceiling, and add --boot fast|small|plain#889
yogthos merged 2 commits into
mainfrom
fix/vfasl-boot-lz4-ceiling

Conversation

@yogthos

@yogthos yogthos commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Two things about the boot image 0.8.5 introduced: one that stops a large binary from starting at all, and the opt-out asked for in #886.

A large binary's boot image loads again

A program big enough for its vfasl boot image to reach 256MiB built fine and then died on every run, inside Sbuild_heap, before a line of its own code had executed:

fasl-read: uncompressed size -222298112 for #vu8(…) is smaller than
           expected size 314572800

The negative number is the tell. Chez's kernel decompresses a fasl entry and compares the result against the size the entry declares; on the LZ4 arm (c/new-io.c, S_bytevector_uncompress) it returns that result as Sfixnum(r) with int r, and Sfixnum is ((ptr)(uptr)((x)*8)) — the multiply happens in the argument's own type. At 2^28 bytes it overflows int, the length comes back negative, and the comparison can never succeed. The gzip arm of the same function hands zlib a uLong and has no ceiling. Measured against Chez 10.4.1: LZ4 round-trips at 2^28-1, fails at 2^28, and gzip round-trips at both.

Nothing before 0.8.5 could reach it. A plain boot is one compressed entry per top-level form and its entries are kilobytes; the vfasl boot 0.8.5 introduced combines each input boot file into ONE entry, so a program's whole compiled half became a single image — 83MB for the build smoke's app, 43MB for jolt itself, and past 256MiB an executable that cannot start. The failure scaled with the program, which is the worst shape for it: every app built with 0.8.5 worked, right up to the one that didn't.

jolt links against whatever Chez the machine has, so it cannot fix the kernel; it keeps the image off the ceiling instead. jolt build now reads back the entry headers of the boot it just converted, and when an LZ4 entry declares an uncompressed size at or over 2^28 it re-encodes the image with gzip and says so. Only a build that was previously broken changes — every image under the ceiling is byte-for-byte what it was — and it keeps the vfasl format, so what it gives up is decompression speed, not the load. The same check covers --library and jolt's own boot.

The codec choice sits behind sa-vfasl-convert-file's new optional argument, since compress-format is a Chez name and the portability gate allows those only in the adapter.

jolt build --boot fast|small|plain picks how the boot is encoded

Closes #886. A vfasl boot is an image of the loaded heap — it starts fast and takes room — and there was no way to decline it: the reported iOS --target tpb64l build grew 7.6MB in the binary and about 5MB in the compressed IPA.

The flag is ordered along the one curve those numbers sit on:

--boot boot image for
fast (default) vfasl, LZ4-compressed the fastest start; today's behaviour, unchanged
small vfasl, gzip-compressed the smallest binary that still loads as an image
plain no vfasl the boot 0.8.4 produced

:jolt/build {:boot :small} and JOLT_BOOT=small follow; --no-vfasl, :no-vfasl true and JOLT_NO_VFASL=1 — the spelling #886 asked for — stay as aliases for --boot plain. Precedence is resolved in one place (flag, then deps.edn, then environment). Covers the self-contained, cc-linked and --library paths; jolt's own boot is not a jolt build and is unaffected.

Why it is a three-way flag and not just an opt-out. The size cost turns out to be mostly the codec's rather than vfasl's, so for a jolt app small beats plain on both axes at once. Measured over two apps and two machine types — binary size and warm start (min of 12 runs), against the plain boot as the baseline, with only the appended boot payload differing between binaries:

app / target plain fast small
hello, host ta6le 25,919,203 · 495ms +5.5% · 249ms −35.7% · 429ms
build-app, host ta6le 26,062,746 · 502ms +5.8% · 250ms −35.5% · 434ms
hello, target tpb64l 24,873,035 +5.8% −38.1%

On tpb64l — the target in #886small lands 9.5MB below the plain boot the report asked for, where the complaint was the default costing 7.6MB. plain stays because a target that cannot vfasl at all still needs it, not because it is the size answer.

The ratios are a property of what is in the image, not of the machine, so nothing user-facing quotes one: the same three encodings over Chez's own boots, which carry no jolt runtime, cost fast +37% and gain small only 3–4%, with small there slower than plain. That is also why the reporter saw +24% where a jolt app sees +6%.

tpb64l numbers were taken by cross-building against a real target pack (Chez 10.4.1, make bootquick XM=tpb64l plus a cross kernel) — no emulation needed, since portable bytecode runs over a native host kernel. Two notes found on the way: plain pb cannot vfasl at all ("cannot vfasl with unknown endianness"), so an endianness-pinned machine is required; and a tpb64l pack whose kernel lacks libffi produces a binary that aborts at startup, because jolt's runtime uses foreign-procedure.

Gates

make vfaslceiling (new, in CI-GATES) pins all three legs the fix rests on — that the ceiling is real and is exactly 2^28, that gzip has none, and that the entry scanner and the fallback do what they claim, the last by lowering the ceiling under a boot small enough to build in a second. The check for LZ4 failing at 2^28 is deliberately a check on the kernel: when a future Chez fixes the overflow it turns red, and that is the signal to delete the workaround rather than a regression. It runs with JOLT_MAX_HEAP=off because two of its checks have to allocate 256MiB to ask the question at all.

build-smoke.sh gains a --boot case covering all three modes: asserted by the artifact (a build that quietly converted anyway is what plain exists to prevent), by the size ordering small < fast and small < plain (which is what catches small silently falling back to the default), by a bad value being rejected, and by running each binary.

Verified locally: vfaslceiling 15/15, buildsmoke (including the new case, through the self-contained path), portcheck, adaptercheck, manifestcheck, readmecheck, completionssmoke, and two full make jolt-release builds. With the ceiling forced to 1024, a real jolt build re-encoded and the resulting binary ran — so a Chez kernel does boot a gzip vfasl image. buildlibsmoke skips on this machine (its Chez is not -fPIC); CI runs it under JOLT_REQUIRE_BUILDLIB=1.

Yogthos added 2 commits September 7, 2026 19:53
…line vfasl

A binary whose vfasl boot image reaches 256MiB died inside Sbuild_heap before a
line of its own code ran, with "uncompressed size -N ... is smaller than
expected size M". Chez's c/new-io.c returns an LZ4 entry's decompressed length
as Sfixnum(r) with `int r`, and Sfixnum is ((ptr)(uptr)((x)*8)) — the multiply
happens in the argument's own type, so 2^28 and up overflow to a negative fixnum
and c/fasl.c's length check can never match. The gzip arm of the same function
hands zlib a uLong and has no ceiling.

0.8.5 is what made it reachable. A plain boot is one compressed entry per
top-level form and its entries are kilobytes; vfasl-convert-file combines each
input boot file into ONE entry, so a program's whole compiled half became a
single image. jolt links against whatever Chez the machine has and cannot fix
the kernel, so it keeps the image off the ceiling instead: build.ss reads back
the converted boot's entry headers and re-encodes with gzip when an LZ4 entry
declares 2^28 or more. Only a previously-broken build changes; every image under
the ceiling is byte-for-byte what it was. Covers the self-contained, cc,
--library and build-jolt paths.

The codec choice sits behind sa-vfasl-convert-file's new optional argument,
since compress-format is a Chez name and the portability gate allows those only
in the adapter.

Also adds --no-vfasl, :jolt/build {:no-vfasl true} and JOLT_NO_VFASL=1, which
keep the plain boot for an app whose download size matters more than its startup
— an iOS --target tpb64l build grew 7.6MB in the binary and ~5MB in the IPA with
no way to decline. Measured on the build smoke's app, one image, three boots:
vfasl+LZ4 27.6MB/0.26s, vfasl+gzip 16.8MB/0.44s, plain 26.0MB/0.50s. The gzip
boot beats the opt-out on both axes, so the tradeoff is documented rather than
assumed; exposing the codec as a build choice is tracked separately.

make vfaslceiling pins the kernel fact, the entry scanner and the fallback, the
last by lowering the ceiling under a boot it can build in a second. Its check
that LZ4 *fails* at 2^28 is deliberately a check on Chez: when a future release
fixes the overflow it turns red, which is the signal to delete the workaround.

Closes #886
The opt-out added for jolt#886 turned out to be the wrong end of the curve to
expose. Measuring the three boot encodings jolt can produce, over two apps and
two machine types (binary size and warm start, against the plain boot):

  hello, host ta6le      plain 25,919,203/495ms  lz4 +5.5%/249ms  gzip -35.7%/429ms
  build-app, host ta6le  plain 26,062,746/502ms  lz4 +5.8%/250ms  gzip -35.5%/434ms
  hello, target tpb64l   plain 24,873,035        lz4 +5.8%        gzip -38.1%

A gzip-compressed vfasl boot beats the plain boot on BOTH axes for every jolt
app measured: about a third smaller and still faster to start, because the size
cost is mostly the codec's rather than vfasl's. On tpb64l — the target in #886 —
it lands 9.5MB below the plain boot the report asked for, where the complaint
was the default costing 7.6MB. So an app that wants a small download wants the
codec, and --no-vfasl alone would have pointed everyone at a floor nobody
should want.

--boot is ordered along that curve: fast (vfasl+LZ4, the default, unchanged),
small (vfasl+gzip), plain (no vfasl). :jolt/build {:boot :small} and JOLT_BOOT
follow; --no-vfasl, :no-vfasl and JOLT_NO_VFASL stay as aliases for `--boot
plain`, which is the spelling #886 asked for. Precedence is resolved once, in
jolt.main — flag, then deps.edn, then environment — rather than half there and
half in build.ss. plain stays available because a target that cannot vfasl at
all still needs it.

The ratios are a property of what is in the image, not of the machine, so
nothing user-facing quotes one: the same three encodings over Chez's own boots,
which carry no jolt runtime, cost fast +37% and gain small only 3-4%, with small
there slower than plain. That is also why the #886 reporter saw +24% where a
jolt app sees +6%.

The build smoke covers all three modes, asserting the artifact (a build that
quietly converted anyway is what plain exists to prevent), the size ordering
small < fast and small < plain (which is what catches `small` silently falling
back to the default), that a bad value is rejected, and that each binary runs.
@yogthos yogthos changed the title Keep the boot image under Chez's LZ4 fasl ceiling, and let an app decline vfasl Keep the boot image under Chez's LZ4 fasl ceiling, and add --boot fast|small|plain Sep 8, 2026
@yogthos
yogthos merged commit 533b04a into main Sep 8, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Let a cross-compiled app keep the plain boot

1 participant