Skip to content

Add RPM packaging - #59

Closed
jameshickman wants to merge 5 commits into
zevv:masterfrom
jameshickman:packaging/rpm
Closed

jameshickman wants to merge 5 commits into
zevv:masterfrom
jameshickman:packaging/rpm

Conversation

@jameshickman

Copy link
Copy Markdown

Adds RPM packaging under packaging/rpm/. No existing files are touched — this
is additive only, so it should have no effect on anyone not building RPMs.

What it builds

Four packages from one spec:

Package Contents
zforth /usr/bin/zforth, the forth/*.zf library in /usr/share/zforth, man page
libzforth libzforth.so.0 — the interpreter as a shared library
libzforth-devel zforth.h, zfconf.h, zforth.pc, embedding example
libzforth-static libzforth.a

Since zForth is aimed at embedding, the library packages seemed at least as
useful as the CLI, so src/zforth/zforth.c is built as a proper shared object
with a soname alongside the interpreter.

Two things worth your attention

The spec doesn't use src/linux/Makefile. It appends -fsanitize=address,
-Os -g and -Werror to CFLAGS/LDFLAGS unconditionally, so a package built
through it would ship an ASan-instrumented binary and would break whenever a new
compiler warns about something new. The spec invokes the compiler directly
instead. Happy to send a separate PR making the Makefile respect externally
supplied flags if you'd prefer packaging go through it — that would be the
tidier long-term arrangement.

Versioning is snapshot-based. There are no tags, so Version is
0^20250815git41db72d, pinned by a commit macro at the top of the spec. Any
tagged release would let this become an ordinary versioned package.

Packaging notes

libzforth.so leaves zf_host_sys(), zf_host_trace() and
zf_host_parse_num() undefined for the embedding program to supply, so they only
bind at run time if the executable exports its dynamic symbols. zforth.pc
therefore carries -Wl,--export-dynamic — without it a linked program builds
cleanly and then fails at startup, which is an unpleasant thing to debug.

zfconf.h is treated as part of the ABI, since ZF_DICT_SIZE and the stack
sizes are compiled into struct zf_ctx. The spec documents bumping the soname
major alongside any change to that header.

%check runs the interpreter against the packaged core.zf, verifies the
library exports the API in zforth.h while leaving only the three zf_host_*
callbacks undefined, and builds the shipped example through pkg-config the way
a consumer of the devel package would.

The man page and the embedding example are written for this package, as the
repo ships neither. Readline is on by default and can be turned off with
rpmbuild --without readline.

Testing

Built with rpmbuild -ba on Fedora 43 (x86_64, gcc 15.3.1), both with and
without readline. Installed the resulting packages and confirmed the interpreter
runs against the installed core.zf, and that the shipped example compiles and
runs against both libzforth.so and libzforth.a via the installed
zforth.pc.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EPozsdjamuCqTHpMym75YF

James Hickman and others added 5 commits August 28, 2026 09:58
Profiling the inner loop with perf shows it is bound purely on instruction
count, not on stalls: a 65M dispatch benchmark runs at IPC 3.0 with 13k branch
misses out of 2.0e9 branches and 25k L1 misses out of 2.5e9 loads. The jump
table the compiler generates for the do_prim() switch does not show up in the
profile at all. The time goes into work done per instruction, so this removes
two pieces of it.

Decode single byte opcodes inline in run(). Most dictionary cells encode as one
byte, but every instruction fetch paid for an out of line call into
dict_get_cell_typed(), which was 22% of the profile on its own.

Only walk the return stack for the trace indent when tracing is actually on.
The loop over RSP(ctx) ran once per instruction regardless, and each iteration
reloaded RSP through ctx->uservar, which aliases ctx->dict and so cannot be
kept in a register.

Together these cut the benchmark from 7.44e9 to 5.22e9 instructions, 114 to 80
per dispatch, and the run time from 0.605s to 0.385s at -O2. Output is
unchanged on core.zf, mandel.zf, dict.zf, memaccess.zf and misc.zf, and the -t
trace output is byte identical.

Build the interpreter at -O2 rather than -Os, worth a further 2.2x, and make
the AddressSanitizer build opt-in with 'make asan=1' instead of linking it into
every build by default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H676rBN7QwnkCHZgBdNKk8
Adds a 'make libs' target building two shared library variants from the same
source and the same zfconf.h. libzforth has the tracing code compiled in,
libzforth-notrace does not.

Compiling tracing in costs about 45% of run time even when tracing is switched
off at run time, because every trace site still tests the flag, and reading it
goes through ctx->uservar, which aliases ctx->dict and so cannot be kept in a
register. On the 65M dispatch benchmark the traced library runs in 0.36s and the
untraced one in 0.21s. The untraced library is also 12 kB smaller and does not
reference zf_host_trace() at all, so a program embedding it does not have to
define that callback.

The two carry different sonames and can be installed side by side. They are
interchangeable at the API and ABI level, since ZF_ENABLE_TRACE does not appear
in struct zf_ctx.

Both are built with -fno-semantic-interposition. zf_push() and friends are
public API, so under -fPIC the compiler otherwise assumes the dynamic linker
could interpose them and emits 70 PLT calls into the inner loop rather than
inlining. That alone was costing the library a factor of two against the same
code linked statically: 0.45s versus 0.22s.

ZF_ENABLE_TRACE in src/linux/zfconf.h is now only defined if not already set, so
the two variants can share one configuration header rather than duplicating a
file whose dictionary and stack sizes are baked into struct zf_ctx.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H676rBN7QwnkCHZgBdNKk8
Packages zForth for RPM-based systems as four packages built from one spec:
zforth (interpreter, Forth standard library, man page), libzforth (the
interpreter as a shared library), libzforth-devel and libzforth-static.

Upstream has no tagged releases, so the spec builds a git snapshot pinned by
a commit macro rather than a release tarball.

The spec drives the compiler directly instead of using the upstream Makefile,
which appends -fsanitize=address, -Os -g and -Werror to CFLAGS and LDFLAGS
unconditionally. Only the distribution build flags apply.

libzforth deliberately leaves zf_host_sys(), zf_host_trace() and
zf_host_parse_num() undefined for the embedding program to supply, so they
only bind at run time if the executable exports its dynamic symbols.
zforth.pc therefore carries -Wl,--export-dynamic; without it a linked program
builds cleanly and fails at startup.

zfconf.h is treated as part of the ABI, since the dictionary and stack sizes
it defines are compiled into struct zf_ctx. The libmajor macro is documented
as needing a bump alongside any change to that header.

%check runs the interpreter against the packaged core.zf, verifies the shared
library exports the API in zforth.h while leaving only the three zf_host_*
callbacks undefined, and builds the shipped example through pkg-config the
way a consumer of the devel package would.

The man page and embedding example are written for this package; upstream
ships neither. Readline is on by default and can be disabled with
rpmbuild --without readline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EPozsdjamuCqTHpMym75YF
Adds libzforth-notrace and libzforth-notrace-devel, built from the same source
and the same zfconf.h as libzforth with ZF_ENABLE_TRACE switched off.

Compiling tracing in costs about 45% of interpreter run time even when it is
switched off at run time through the 'trace' user variable, because every trace
site still has to test the flag. The untraced library is a third smaller and
does not reference zf_host_trace(), so a program embedding it does not have to
define that callback. The two carry different sonames and can be installed side
by side; they are interchangeable at the API and ABI level, since
ZF_ENABLE_TRACE does not appear in struct zf_ctx.

The untraced build derives its zfconf.h with sed from the one the traced build
uses rather than carrying a second copy in the spec, so the two cannot drift
apart in the dictionary and stack sizes that are baked into struct zf_ctx. A
grep after the sed fails the build if it did not match, so an upstream rename of
ZF_ENABLE_TRACE cannot silently produce two identical libraries.

Both shared libraries are now built with -fno-semantic-interposition. zf_push()
and the other stack calls are public API, so under -fPIC the compiler otherwise
assumes the dynamic linker could interpose them and emits PLT calls into the
inner interpreter instead of inlining, which was costing the shared library a
factor of two against the same code linked statically.

%check now runs the symbol and pkg-config checks over both libraries, and
asserts that libzforth-notrace really does leave zf_host_trace() out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H676rBN7QwnkCHZgBdNKk8
Moves the pinned commit to 848458c, which carries the inner interpreter work and
the shared library build, and resets Release for the new snapshot version.

The packaged libraries now show the speedup. Rebuilding both snapshots with the
same distribution flags and running the 65M dispatch benchmark against each:

  libzforth          0.57s -> 0.38s   133 -> 92 instructions per dispatch
  libzforth-notrace  0.32s -> 0.22s    76 -> 48 instructions per dispatch

Both are a little slower than a plain -O2 build of the same source because of
the distribution hardening flags, which is the expected trade.

The untraced variant is now built with -DZF_ENABLE_TRACE=0 rather than a sed
over zfconf.h. The pinned tree only defines ZF_ENABLE_TRACE if it is not already
set, so the two variants share the one configuration header directly and the
scaffolding that made that safe against the old unconditional define is no
longer needed. The zf_host_trace() assertion in %check still covers the case
where that guard goes away again.

Also drops the note about the upstream Makefile linking AddressSanitizer into
every build, which the pinned tree fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H676rBN7QwnkCHZgBdNKk8
@jameshickman

Copy link
Copy Markdown
Author

Withdrawing this for now.

The branch has since been rebased onto some inner interpreter performance work, which I have opened separately as #60, and the spec here now pins a commit that only exists on top of that. So this PR no longer does what its title says, and it is not reviewable on its own in its current state.

I would rather not have packaging land ahead of the code it packages anyway. If #60 is something you want, I will reopen this rebased onto whatever it becomes, pinned to a commit that actually exists here. If you would rather not carry distribution packaging in the tree at all, that is a completely reasonable answer too and I will keep it in my fork.

Sorry for the churn.

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.

1 participant