Repository navigation
Add Lean 4 solution_2: optimized faithful base sieve, 8-bit flags - #1079
Conversation
rbergen
left a comment
There was a problem hiding this comment.
Thanks for the detailed write-up, and for making both disclosures up front. That was the right call.
I built and ran both containers on the same machine, native x86-64, no emulation, best of 5 runs each:
| Solution | passes / 5s |
|---|---|
| solution_1 | 2937 |
| solution_2 | 13769 |
That is 4.7x, not 7.1x. The extra margin is an emulation artifact, so the "consistent with native runs" note doesn't hold. Please restate the figure as ~4.7x in both the PR description and the README, or label it explicitly as measured under emulation. It's still a large win and doesn't need the headroom.
On a new solution rather than improving solution_1
A new solution is warranted here, but not for the reason you give.
Toolchain and build system updates are not grounds for a separate solution. This project compares implementations, not toolchains. A move off leanpkg and onto a current Lean is warranted simply because time has passed, and that kind of change can be rolled into an update of an existing solution. On its own it would have pointed at improving solution_1.
What does justify a separate solution is that your storage approach is genuinely different. solution_1 stores flags in an Array Bool; you store them in a ByteArray. Those are different data types with different layouts, and the difference is large enough to change a key characteristic - see below. Please restructure the PR description and the README to lead with that. The toolchain migration is incidental and should be described as such.
solution_1's bits=8 is wrong, and yours isn't
This is worth stating explicitly because it is the cleanest justification for your PR.
From include/lean/lean.h in the toolchain you pin, Array stores one object reference per element:
typedef struct {
lean_object m_header;
size_t m_size;
size_t m_capacity;
lean_object * m_data[];
} lean_array_object;Nothing is allocated behind those references for a Bool. Scalars are tagged immediates, encoded in the word itself:
static inline lean_object * lean_box(size_t n) { return (lean_object*)(((size_t)(n) << 1) | 1); }So a Bool slot holds the literal word 1 or 3, and the slot is the entire per-flag cost. That matches measurement: a 20,000,000-element Array Bool moves VmRSS from 8,232 kB to 166,068 kB, or 8.08 bytes per flag on x86-64. A per-flag heap object would cost 24 bytes or more, since a Lean object header alone is 16.
A flag therefore occupies one machine word, not one byte, and bits=8 is wrong.
But the exact count is not a property of the implementation. It is sizeof(lean_object*) - 8 bytes on a 64-bit target, 4 on a 32-bit one - so bits=64 would overstate what can be claimed. CONTRIBUTING covers this case directly: unknown applies when "an implementation uses a boolean basic type provided by the language, and the language does not define how booleans are logically stored in memory". solution_1 should be bits=unknown.
Your storage is a different type with a fixed width, from the same header:
typedef struct {
lean_object m_header;
size_t m_size;
size_t m_capacity;
uint8_t m_data[];
} lean_sarray_object;ByteArray is one byte per flag on any target, so your bits=8 is correct and portable. The two solutions differ on a badge characteristic, not just internally, and should not be merged into one entry.
I'll open a PR to fix solution_1's labeling myself, that's not on you.
Please fix: image size
The image is 4.47GB, against 1.55GB for solution_1. CONTRIBUTING asks for an AS build stage when a solution needs significantly more to build than to run, and this is a clear case: the binary statically links GMP and needs nothing beyond base libc. I tested a two-stage build - 123MB, identical throughput.
FROM ubuntu:24.04 AS build
ARG ELAN_VERSION=v4.2.4
# build-essential and libgmp-dev are needed to link the Lean executable.
# hadolint ignore=DL3008
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential ca-certificates curl libgmp-dev \
&& rm -rf /var/lib/apt/lists/*
# elan's bundled libcurl does not pick up Ubuntu's CA bundle on its own.
ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
ENV SSL_CERT_DIR=/etc/ssl/certs
RUN arch="$(uname -m)" \
&& case "$arch" in \
x86_64) target="x86_64-unknown-linux-gnu" ;; \
aarch64) target="aarch64-unknown-linux-gnu" ;; \
*) echo "unsupported architecture: $arch" >&2; exit 1 ;; \
esac \
&& curl -fsSL -o /tmp/elan.tar.gz \
"https://github.com/leanprover/elan/releases/download/${ELAN_VERSION}/elan-${target}.tar.gz" \
&& tar -xzf /tmp/elan.tar.gz -C /tmp \
&& /tmp/elan-init -y --default-toolchain none \
&& rm -f /tmp/elan.tar.gz /tmp/elan-init
ENV PATH="/root/.elan/bin:${PATH}"
WORKDIR /opt/app
COPY lean-toolchain lakefile.toml PrimeLean4.lean ./
RUN elan toolchain install "$(cat lean-toolchain)" \
&& lake build
FROM ubuntu:24.04
WORKDIR /opt/app
COPY --from=build /opt/app/.lake/build/bin/PrimeLean4 ./PrimeLean4
ENTRYPOINT ["./PrimeLean4"]On the toolchain acquisition
elan is fine. The rule is aimed at opaque binaries, not a language project's own release channel, and the precedent you point at is accurate.
The rest verifies
Output validates at 78498 with a 500001-byte buffer, the output format and tags are correct, hadolint passes against config/hadolint.yml, and both architectures build. Faithfulness holds: Sieve carries the full state, a fresh instance is created per pass, the buffer is allocated at runtime at exactly 1 + n/2, and the run returns the flag array.
Your implementation notes understate one thing
I measured your three flagged items by building single-variable variants of your own solution. All three are real: the hand-rolled zero-fill is worth 1.82x, the proof-carrying uget/uset 1.24x, and ByteArray over Array Bool 2.44x.
The tail-recursion note is the one you undersell. Rewriting the hot loops as while loops over mutable USize state costs 9.6x - that variant is slower than solution_1. The generated C shows why:
v___x_71_ = lean_box_usize(v___x_70_); // heap allocation, every iterationlean_box_usize allocates, because a USize cannot be a tagged immediate the way a small Nat can. So it's a malloc per marked composite. It's also why Nat with while loops beats USize with while loops by 4.5x in my measurements, which is counterintuitive enough to be worth stating plainly in the README.
One correction: the comment on Sieve.run claims the destructuring is what keeps the buffer uniquely referenced. On v4.33 it makes no measurable difference - { s with bits := ... } compiles to the same thing. Either drop that comment or soften it.
Summary of what I need from you
- Restructure the PR description and README to justify the new solution on the storage difference, not the toolchain migration.
- Correct 7.1x to ~4.7x, or label it as emulated.
- Switch the Dockerfile to the two-stage build above.
- Drop or soften the
Sieve.rundestructuring comment.
AI disclosure
This review was drafted with AI assistance. I take ownership of its contents and stand by them personally.
|
Thanks — this was a genuinely useful review, and the storage argument is a better justification than the one I made. All four items are addressed in 3d8eedf. 1. Justify on storage, not toolchainRestructured both the PR description and the README to lead with Your framing of the 2. The 7.1xCorrected to ~4.7x in the PR description and the README, with a note that the earlier figure came from containers under x86-64 emulation. I can't reproduce a native x86-64 run on this host, so the README attributes the 2937 / 13769 figures to measurement during review rather than presenting them as mine. 3. Two-stage buildSwitched to your Dockerfile as written. Built natively on arm64 here, the image is 145MB against 4.47GB before. The gap from your 123MB is the base image difference — Throughput is unchanged by construction, since the build stage is identical and only what gets copied out of it differs; the container run confirms it at 13343 passes per 5s against 13759 for the same binary run on the host in the same session. 4. The
|
solution_1 targets leanprover/lean4:4.0.0-m2 with leanpkg and states that little optimization was attempted. This adds a reimplementation on a current toolchain (v4.33.0, built with lake) that keeps the same algorithm and characteristics but avoids the boxing and allocation costs that dominate a direct transcription, running roughly 7x faster in equivalently built containers.
Restructures the README to justify a separate solution on the flag storage
type (ByteArray vs solution_1's Array Bool, a badge-visible difference) and
describes the lake/v4.33.0 migration as incidental.
Corrects the solution_1 comparison to ~4.7x, measured natively on x86-64;
the previous 7.1x came from containers under emulation.
Replaces the single-stage Dockerfile with a build stage that copies only the
linked binary into the runtime image: 4.47GB to 145MB on arm64, with
throughput unchanged. The binary statically links GMP and needs only libc.
Softens the Sieve.run destructuring comment, which claimed an in-place
update benefit that { s with .. } also gets on v4.33, and states the USize
mutable-state boxing cost plainly in both the source and the README.
3d8eedf to
e7f63d1
Compare
rbergen
left a comment
There was a problem hiding this comment.
I'm writing this comment without AI assistance: thank you for incorporating the changes I requested. This solution looks good to me now, and I'm happy to merge it.
With regards to the numbers I mentioned that you included: that's your call, and I believe you've already made it.
Thank you for your contribution!
|
Thank you for merging! |
Review feedback on PlummersSoftwareLLC#1086: the label is right but the argument was not. Rest the claim on every mask bit being an identifiable marking operation in the source, composed at run time after the prime is discovered, and cite PrimeV/solution_2's dense_bitset and PrimeAssembly/solution_4's cwager_x64ff_mt_extreme_maskgen_onefactor. The latter is the closer match since it composes a scratch mask and then applies it rather than fusing the two. Nim's denseSetBits and PrimeSwift/solution_1's dense small-factor path move to related-technique mentions: both fuse per-composite ORs into the sieve word, and their thresholds follow from word granularity, not classification. Separate composing a mask from applying it, since only the first earns the label. Composing steps the prime through one period a multiple at a time; applying walks the buffer once per byte. Also: masks and strides are loop-invariant runtime values, not constants; drop the now-merged PlummersSoftwareLLC#1079 reference; record the reviewer's native x86-64 figures as attributed rather than claimed.
… marking (#1086) * [Lean] Add Lean 4 solution_3: 1-bit base sieve, striped and dense marking One bit per flag in a ByteArray, single-threaded and faithful, with the buffer sized to exactly ceil((1 + sieveSize / 2) / 8) bytes. Bit packing alone loses in Lean: computing i / 8 and i % 8 per write measured 20% slower than solution_2's byte per flag. Two marking techniques recover it. Striped marking splits a prime's multiples into eight subsequences by bit position, so each loop has a constant mask and a constant byte stride and no bit arithmetic survives the hot path. Worth 1.38x over solution_2. Dense marking handles 3 and 5, where a byte holds several multiples and striped stores keep re-reading the same byte. One store per byte with a constant mask per byte index modulo p, unrolled p times. Break-even sits just under p = 7 (47.7us striped against 21.8us dense for the multiples of 3, dense stores being slightly dearer each), so 7 upward stay striped. Worth a further 1.16x. Both keep algorithm=base: each mask carries one prime's bits and every composite is reached by stepping that prime, as Nim solution_3's denseSetBits does at word granularity. 22616 passes against 14123 for solution_2, interleaved best-of-three on an M4 Max. Counts match trial division for every sieve size from 0 to 3000. * PrimeLean4/solution_3: rest the base claim on per-bit mask composition Review feedback on #1086: the label is right but the argument was not. Rest the claim on every mask bit being an identifiable marking operation in the source, composed at run time after the prime is discovered, and cite PrimeV/solution_2's dense_bitset and PrimeAssembly/solution_4's cwager_x64ff_mt_extreme_maskgen_onefactor. The latter is the closer match since it composes a scratch mask and then applies it rather than fusing the two. Nim's denseSetBits and PrimeSwift/solution_1's dense small-factor path move to related-technique mentions: both fuse per-composite ORs into the sieve word, and their thresholds follow from word granularity, not classification. Separate composing a mask from applying it, since only the first earns the label. Composing steps the prime through one period a multiple at a time; applying walks the buffer once per byte. Also: masks and strides are loop-invariant runtime values, not constants; drop the now-merged #1079 reference; record the reviewer's native x86-64 figures as attributed rather than claimed. --------- Co-authored-by: Rutger van Bergen <rbergen@xs4all.nl>
Description
This adds
PrimeLean4/solution_2: a Lean 4 implementation of the base sieve storing one byte per flag in aByteArray, single-threaded and faithful.Revised after @rbergen's review; a point-by-point response is in the comments below.
Why a separate solution rather than improving solution_1
The substantive difference from
solution_1is the flag storage type, and it is visible in a badge characteristic.solution_1 stores flags in an
Array Bool. Frominclude/lean/lean.hin the pinned toolchain, anArrayholds one object reference per element:Nothing is allocated behind those references for a
Bool, since scalars are tagged immediates encoded in the word itself (lean_boxis(lean_object*)((n << 1) | 1)). So aBoolslot holds the literal word and the slot is the entire per-flag cost: one machine word, which issizeof(lean_object*)and therefore target-dependent.This solution stores flags in a
ByteArray, a different structure with a fixed element width:That is one byte per flag on any target, so
bits=8here is correct and portable in a way it is not for anArray Bool. The two solutions differ on a badge characteristic, not just internally.The build also moves from
leanpkgon the 20214.0.0-m2toolchain tolakeonv4.33.0. That is incidental — it is needed simply because time has passed, and on its own it would have pointed at improving solution_1 rather than adding an entry.Results
Against solution_1, measured on native x86-64 during review, best of five runs:
About 4.7x. An earlier revision of this description claimed 7.1x from containers built and run under x86-64 emulation on an ARM host; that margin was an emulation artifact and the figure is corrected here and in the README.
Run natively on an Apple M4 Max, solution_2 reaches 2831 passes/second. As a cross-language sanity check on the same machine, base sieves with 8-bit flags that I wrote directly in C and Rust — same algorithm, same odd-only byte buffer, same 5-second harness — reach 3051 and 3009 passes/second. Those are my own reference implementations, not solutions from this repository, and all figures are best-of-three on one machine. They say nothing about placement on the benchmark server.
The solution validates its own output: it prints the prime count and the buffer size to stderr before the timed run, so the count of 78498 and the exact 500001-byte buffer can be checked rather than taken on trust.
Implementation
Storage is odd-only, one byte per odd candidate, so the buffer is exactly
1 + sieveSize / 2bytes and stepping the index byfactoris stepping the number by2 * factor. A freshSieveis constructed per pass and the buffer is allocated inside it.Four things in the source are Lean-specific and worth flagging for review, since they look unusual next to the C and Rust solutions. All four are explained in comments in the source and in the README:
whileloops over mutable state. This is the largest single factor, and the reason is counterintuitive: aUSizeheld as a mutable binding is heap-allocated every iteration, appearing in the generated C as alean_box_usizecall inside the loop body, so that form costs a malloc per marked composite. AUSizecannot be a tagged immediate the way a smallNatcan, which is why the apparently-cheaper unboxed integer type is the more expensive choice in that position. As function parameters, as here, they are unboxedsize_t.len ≤ bits.sizeand derive each access's obligation from the loop comparison already performed, souget/usetcompile to plain indexed loads and stores. The safeget!/set!would repeat that comparison at run time. This moves the check to compile time; it does not skip it, and Lean's equivalent of-no-bounds-checkis not used.ByteArrayzero-fill primitive, and the obvious⟨Array.replicate n 0⟩first builds a boxedArray UInt8at one word per element — a 4 MB intermediate for a 500 KB buffer. The replacement doubles an ownedByteArrayand does one final partial append so the result is exactly the requested size rather than rounded up.Sieve.create szis pure and loop-invariant and the compiler would otherwise be free to hoist it out and run the sieve once. This is the same guard the C solutions get fromvolatileand the Rust ones fromblack_box: one pointer load per pass, and it does not touch the sieve.Disclosures
Toolchain acquisition. The Dockerfile installs the official
elantoolchain manager from a pinned leanprover release, which then fetches the pinned Lean release, rather than building Lean from source in the image. Confirmed acceptable in review as a language project's own release channel rather than an opaque binary.AI assistance. This implementation was developed with AI assistance. The algorithm, the measurements, and the reasoning above are ones I reviewed and stand behind, and every figure in this PR is reproducible by building and running the solution as submitted. I am disclosing it because § Submission quality makes clear that good-faith effort matters to you, and I would rather state it up front than have it inferred.
Contributing requirements
drag-raceas the target branch.