Skip to content

Add support for a classic monolithic OS#2549

Draft
stlankes wants to merge 46 commits into
hermit-os:mainfrom
stlankes:fork
Draft

Add support for a classic monolithic OS#2549
stlankes wants to merge 46 commits into
hermit-os:mainfrom
stlankes:fork

Conversation

@stlankes

Copy link
Copy Markdown
Contributor

In principle, this PR add options to support a common fork and a system call, which is comparable to posic_spawn.

The original code was developed by @Vinc-F I revised the code, rebase it to the current main branch and remove some bugs,

cagatay-y and others added 30 commits July 12, 2026 10:48
Allows creating packet capture files in the pcap format.

Co-authored-by: Martin Kröning <mkroening@posteo.net>
Allows creating packet capture files in the pcap format.

Co-authored-by: Martin Kröning <mkroening@posteo.net>
Allows creating packet capture files in the pcap format.

Co-authored-by: Martin Kröning <mkroening@posteo.net>
Allows creating packet capture files in the pcap format.

Co-authored-by: Martin Kröning <mkroening@posteo.net>
- Add sys_fork syscall entry and prepare_fork_child_stack (x86_64)
  using a naked function that pushes fork_child_start as the child's
  return address via RIP-relative lea
- Introduce restore_context_without_return! macro so the parent path
  can correctly skip the pushed address and return false
- Mark user pages COW before duplicating the PML4; handle COW page
  faults and heap demand-paging in the page fault handler
- Copy kernel stack to child's new stack allocation and compute the
  child's rsp from the parent-to-child base address offset
- Add Task::new_fork and set_current_kernel_stack/Cr3 switch on
  context switch to load the child's page table
- Mask TF in IA32_FMASK (SFMask) to prevent spurious #DB exceptions
  in child tasks that inherit the parent's RFLAGS via R11/sysretq

The original PR was written by Vincent Feinendegen.
- load_application: initialise a fresh file-descriptor object map for
  the user process instead of sharing the kernel task's map
- fork: clone the parent's object map into the child rather than sharing
  the same Arc, so parent and child have independent fd tables
- Replace redundant `as *mut PageTable` casts with turbofish syntax
  on `ptr::with_exposed_provenance_mut`
- Replace `core::ptr::write_bytes` with pointer method `.write_bytes()`
- Replace `core::ptr::copy_nonoverlapping` with `dst.copy_from_slice(src)`
  in physicalmem and `*new_pt = cur_pt.clone()` in mm/mod.rs
- Add missing semicolon after `without_interrupts` call in scheduler
Before this change, the COW page-fault handler always allocated a fresh
physical frame and mapped it, but never freed the original shared frame.
This caused a 4 KiB leak per write fault on a COW page.

Introduce a sparse per-frame reference count (BTreeMap<frame_nr, u32>
protected by InterruptTicketMutex) that tracks how many page-table
entries point to each COW-shared frame.  Memory use scales with the
number of actively shared frames, not with total physical memory.

- mark_user_pages_copy_on_write: increment refcount for every newly
  COW-marked frame (parent's reference)
- copy_current_root_page_table: increment refcount for every COW entry
  copied into the child's page table (child's reference)
- COW fault handler: decrement refcount; if the last reference is
  dropped, remap the existing frame as writable directly instead of
  copying it (avoids an allocation in the single-sharer case); otherwise
  copy the frame and map the private copy
Register page faults as IRQ 14 in the interrupt counter so they
show interrupt diagnostics.  Useful for verifying COW behaviour
after fork.
The spawn_process function is provided by the loader and declared
as an external C symbol.  The kernel routes syscall 16 to it.
A stub returning -ENOSYS is provided for non-common-os builds.

Also demote a log message in jump_to_user_land from info to debug.
Under `common-os`, each process receives its own `object_map` when
the application is loaded, so the shared stdio map in `Task::new_idle`
was never actually used. Gate the static and its stdin/stdout/stderr
setup with `cfg(not(feature = "common-os"))` and give the idle task
an empty map in the common-os case.
`copy_kernel_stack_to` allocated fresh physical frames via
`copy_page` for every mapped page of the parent kernel stack and
remapped them at the child's virtual base — but `TaskStacks::new`
had already allocated and mapped the child's stack frames at that
same address. The new mappings silently replaced the original ones,
so `TaskStacks::Drop` later freed the unreachable frames while the
frames actually installed in the page table leaked.

Replace the `copy_page` + `map` loop with a plain
`copy_nonoverlapping` between the parent's and child's stack
ranges, which are both already mapped in the current page table.
No extra frames are allocated, and `TaskStacks::Drop` once again
frees the frames that are actually installed.
Extend SYSHANDLER_TABLE to 64 slots and register spawn, join,
file metadata, condvar, task blocking and socket syscalls.
Socket entries are gated behind `net`/`virtio-vsock`.

Make the `addrinfo` submodule and its types `pub(crate)` so the
table can take function pointers to `sys_getaddrinfo` and
`sys_freeaddrinfo` without leaking private types.
Implement std::thread::spawn support when running as a common OS.
Each spawned thread gets its own private TLS region, initialized from a
pristine PT_TLS template extracted directly from the ELF binary. Threads
share the parent's address space via Arc<RootPageTable>, heap, and file
descriptor table, but have independent kernel/user stacks and TLS blocks.

Key changes:
- Add TlsTemplate struct holding the ELF's PT_TLS init image
- Add RootPageTable wrapper with Arc-based lifetime management
- Add allocate_thread_tls() to map fresh per-thread TLS pages
- Add task_start_user() naked trampoline for ring-3 entry via iretq
- Route sys_spawn/sys_spawn2 through spawn_thread() under common-os
- Handle user thread exit via page fault at RIP=0 (clean exit path)
- Register available_parallelism and getdents64 syscalls
Split drop_user_space into a reusable clear_pml4 helper that tears down
user-space page tables without freeing the PML4 itself. clear_user_space
uses it to wipe the current address space in-place and flush TLBs,
enabling exec-style process replacement. drop_user_space continues to
free the PML4 frame after clearing.

Also register the new sys_exec syscall (number 56) in the dispatch table
with a weak default stub.
Avoids leaking kernel-state register values to ring 3; the user
entry sees only rdi (argc) and rsi (argv).
This series makes Hermit's aarch64 common-os kernel boot under
macOS HVF on Apple Silicon, in addition to the existing TCG support.
Three small adjustments are needed: virtual timer instead of physical,
PSCI shutdown instead of semihosting, and FEAT_PAN-aware EL1 setup.
Mirrors the existing x86_64 common-os support in arch/aarch64/mm:

* New software-defined PT-entry flag COW_MARKER (bit 58) plus the
  `copy_on_write()`, `user()`, `kernel()`, `execute_enable()` helpers
  on PageTableEntryFlags, and a PageTableEntryFlagsExt trait that
  matches the x86_64 API so common-os callers compile unchanged.

* `mark_user_pages_copy_on_write()` walks the active TTBR0_EL1 user
  L0 entry and marks every writable user page READ_ONLY+COW_MARKER —
  the prep step before duplicating a page table for fork().

* `create_new_root_page_table` / `copy_current_root_page_table` /
  `drop_user_space` / `clear_user_space` / `copy_kernel_stack_to`
  manage per-process root tables. Kernel L0 entries (#0 kernel image,
  hermit-os#256+ kernel heap, hermit-os#511 self-ref) are SHARED across every task; only
  the user slot (#2 = LOADER_START >> 39) is private and deep-copied
  on fork. clear_l0 only walks the user slot and only frees a sub-table
  when the user-page sweep leaves it fully empty, so the kernel's own
  heap and stacks (which live in shared L0 entries) stay mapped.

* L0 self-reference at 0x0000_FFFF_FFFF_F000 lets PT walks reach any
  level via virtual addresses, matching the existing x86_64 recursive
  PML4 idiom.

mm/physicalmem.rs and scheduler/{mod,task/mod}.rs lift the
`x86_64 + common-os` cfg-gates around frame_ref_inc/dec, copy_page,
RootPageTable, Heap and the related Task fields to plain
`feature = "common-os"`. Items still architecturally x86_64-only
(fork(), spawn_thread, prepare_fork_child_stack, sys_fork/sys_waitpid)
remain re-gated; aarch64 picks them up incrementally in the later
stages of this series.
Common-os entry path for AArch64, modelled on the x86_64 sibling:

* `load_application(code_size, tls_size, func)` allocates the user
  load region at LOADER_START (= 1 TiB), maps it with USER + writable
  + execute-enable flags, runs the loader closure to copy ELF segments
  and the PT_TLS image, and installs TPIDR_EL0 for AArch64 Variant I
  TLS (TCB at TPIDR_EL0, TLS image after a 16-byte reserved area).
  Frame refcounts are bumped so the COW machinery in Stage 5 can drop
  them safely on fork/exec teardown.

* `jump_to_user_land(entry, code_size, argv)` builds the user stack
  with argv strings + pointer array (16-byte aligned per AAPCS64; no
  red-zone trick), then transitions to EL0 with `eret`:
    msr sp_el0,   <stack>   ; user SP
    msr elr_el1,  <pc>      ; user PC
    msr spsr_el1, #0        ; M[4:0]=0b00000 ⇒ EL0t, DAIF cleared
    mov x0, argc; mov x1, argv
    ; zero scratch GPRs to avoid leaking EL1 state
    eret
    dsb nsh; isb            ; speculation barrier behind ERET

  No naked-asm "child entry stub" is needed (unlike x86_64's
  fork_child_start) — every task return path goes through the trap
  frame, which is built up exactly the same way.
Wires the user-space syscall path so EL0 binaries reach the existing
SYSHANDLER_TABLE.

* start.s: replace the `el0_sync_invalid` / `el0_irq_invalid` stubs
  with real handlers that run trap_entry / do_sync(_irq) / trap_exit
  / eret. Hardware switches to SP_EL1 on entry from EL0, so no
  explicit `msr spsel` is needed. The AArch32 Lower-EL slots stay on
  the invalid path because we do not run 32-bit user code.

* interrupts.rs: do_sync now takes `&mut State` so it can write the
  syscall return back into the trap frame. New dispatch_svc64 reads
  x8 (syscall number) and x0..x5 (args) from the saved State, looks
  the handler up via SyscallTable::handler(nr), and stores the result
  in state.x0; trap_exit then ERETs with the right return value.

* syscalls/{mod,table}.rs: make the syscall table available on
  aarch64 + common-os and provide an arch-conditional sys_invalid
  (the x86_64 naked-asm inline trampoline stays unchanged; aarch64
  uses an empty Rust stub used purely as a sentinel, with a pointer
  comparison in the dispatcher to detect unfilled slots).

The Linux-like AArch64 SVC ABI matches what hermit-rs already emits:
  svc #0; nr in x8; args x0..x5; return in x0.
Completes the common-os process model on AArch64:

* paging.rs: do_cow_fault(virt) walks down to the L3 entry of a
  user-space write fault, dec/incs the per-frame refcount, copies the
  page if shared (talc-allocated) or just flips RO->RW + clears
  COW_MARKER if exclusively owned, and invalidates the single TLB
  entry via `tlbi vale1is`. flush_tlb_one helper added alongside.

* interrupts.rs: do_sync's DataAbortLowerEL/CurrentEL branch now
  decodes ESR_EL1.ISS (DFSC, WnR), routes write+permission faults to
  do_cow_fault, and on unhandled faults prints a human-readable
  reason via a new dfsc_kind() table. A synthetic IRQ slot
  PAGE_FAULT_IRQ = 14 is registered in IRQ_NAMES so the page-fault
  count surfaces in print_statistics like on x86_64.

* scheduler.rs: prepare_fork_child_stack mirrors the x86_64 helper
  but does not need a naked-asm child-entry stub — the trap frame
  pushed by trap_entry is already a complete EL0 return descriptor.
  The function copies the parent's kernel stack, snapshots the root
  page table, locates the equivalent State address in the child's
  copied stack, patches state.x0 = 0 (fork-returns-zero contract),
  and hands the child SP back to fork().

  get_last_stack_pointer additionally installs the new task's
  root_page_table into TTBR0_EL1 with the full DSB ISHST / MSR /
  ISB / TLBI VMALLE1IS / DSB ISH / ISB sequence (ARM ARM D8.13.2).
  Without this the scheduler would leave the previous process's PT
  active and a freshly-forked child would run in its parent's
  address space.

* mod.rs: set_user_tpidr_el0(value) updates the live register *and*
  patches the saved trap frame's tpidr_el0 field, so the value
  installed by load_application survives trap_exit. Used by both
  the TLS and the no-TLS branch of load_application; the latter
  resets TPIDR_EL0 to 0 so a TLS-less exec() does not inherit the
  previous program's thread pointer.

scheduler/mod.rs gates fork() on common-os (was x86_64+common-os),
syscalls/tasks.rs likewise for sys_waitpid, and syscalls/table.rs
registers SYSNO_FORK/WAITPID for both architectures.
In the case of a monolithic kernel, the "GLOBAL" flag is set in
the page table entries for the kernel space. This prevents TLB
entries from being flushed for the kernel, because the kernel is
always mapped to the address space of any application.
The Heap struct and the user-mode page-fault path that lazily mapped
heap pages are no longer needed; user heap pages are mapped eagerly.
Drops Task::heap, parent_heap propagation through fork()/new_thread(),
and the corresponding USER_MODE branch in the x86_64 #PF handler.
- Move allocate_thread_tls into arch::mm so each arch implements its
  own TLS variant (x86_64: Variant II, aarch64: Variant I via TPIDR_EL0).
- Add Task::create_user_stack_frame on aarch64 to craft the initial
  trap frame; the existing trap_exit machinery eret's straight into EL0.
- Mark the user stack USER_ACCESSIBLE under common-os so a freshly
  spawned thread can touch its own stack.
- Treat an EL0 instruction abort at PC=0 (entry wrapper returning into
  zeroed LR) as a clean thread exit, mirroring the x86_64 behaviour.
- Generalise spawn_thread / NewTask::tls_base and route sys_spawn{,2}
  through spawn_thread on aarch64 too.
copy_current_root_page_table only inc'd PAGE_REFCOUNTS for entries
carrying the COW marker, but mark_user_pages_copy_on_write skips
read-only pages (text, rodata). The child's clear_user_space then
dec'd every USER_ACCESSIBLE entry, dropping the refcount to zero
and freeing frames the parent still mapped — a use-after-free of
the parent's text segment.

Inc for every PRESENT|USER_ACCESSIBLE entry so the recorded
refcount matches the number of address spaces actually pointing at
the frame. Same fix on aarch64 and x86_64.
jump_to_user_land never returns — `eret`/`iretq` transfer to EL0
and any owned heap allocation on the kernel stack is leaked
forever. Switch the argv parameter from a borrowed slice to an
owned Vec<&str> and drop it explicitly after argv is materialised
on the user stack but before the speculation-fenced eret. Mirrors
the existing drop(elf)/drop(buffer)/drop(file) pattern in
boot_image::loader.
Adds VirtualMemoryArea (start, end, protection) and a per-address-space
BTreeMap<VirtAddr, VMA> as Task::vmas. The map is propagated through
NewTask::from, PerCoreScheduler::{spawn,spawn_thread} and fork() so
threads share their parent's VMA list while a fresh process starts
empty and a forked child gets a deep-copied snapshot.

The new sys_mmap (SYSNO 57) supports two shapes: with a null `ret` it
creates a fresh anonymous VMA at HEAP_START_ADDR for the initial user
heap; with a non-null `ret` it extends the predecessor VMA in place,
bounded by the next VMA's start. No frames are mapped here — that is
handled lazily on the page-fault path.
load_application now records one VMA for the [LOADER_START ..
LOADER_START + code_size) range (RWX, matching what's actually mapped)
and a second one for the per-process TLS region (RW). With these in
place the page-fault handler can later resolve faults in user-loaded
binaries against the VMA table.

The x86_64 path takes the long way around two VirtAddr newtypes: this
file imports memory_addresses::VirtAddr, but Task::vmas is keyed by
x86_64::VirtAddr. Convert at the insert boundary with .into() — the
two are structurally identical, so the conversion is free at runtime.
stlankes and others added 13 commits July 12, 2026 12:32
On a user-mode translation fault (no existing mapping, no COW marker)
walk the current task's VMA tree, allocate a fresh frame and map it
with permissions derived from VMA::prot. This is what makes anonymous
sys_mmap regions actually faultable — the BTreeMap entry alone reserves
VA space, the first access pulls in a frame.

The x86_64 sibling additionally restores user GS via `swapgs` before
returning. The COW path was already doing this; the new VMA branch
forgot it, so iret left the kernel GS-base installed and the next
FS/GS access in ring 3 read kernel memory — rusty_demo would freeze
just after printing "Arguments:" because env::args() touches TLS.
Task::{last,user}_stack_pointer is x86_64::VirtAddr; TaskStacks::get_*()
returns memory_addresses::VirtAddr. The two newtypes have identical
layouts but the type system rejects mixing them. Add .into() at the
two assignment sites in create_user_stack_frame and create_stack_frame.
HEAP_START_ADDR sat at 0x7100_0000_0000, which falls into L0[226] —
outside the slot that aarch64's mark_user_pages_copy_on_write,
copy_current_root_page_table and clear_l0 actually walk. The latter
three only touch `USER_L0_INDEX = LOADER_START >> 39 = 2`, so the
heap was effectively a *shared* mapping: parent and child were
reading and writing the same physical frames. The fork_bench
warmup hit this immediately — the child's first user-space write
(`*pid = 0`) overwrote the parent's `pids[1]`, the parent then
called `waitpid(0)`, queued on the idle task, and hung.

Move HEAP_START_ADDR to 0x0140_0000_0000, well above any loaded
LOADER image but still inside L0[2]. The constant remains
canonical on x86_64 (bit 47 = 0). Also drop a stray Errno typo
(`Nomems` → `Nomem`) the change uncovered, and tidy the riscv /
x86_64 VirtAddr import order while we're here.
When the VMA-aware page-fault handler maps a fresh frame for an
anonymous user mapping, FrameAlloc can hand back a recycled frame
whose previous contents are stale. The user code then sees garbage
where it expects zero — most visibly in std startup paths where
allocator metadata, futex words and once-flags decide control
flow. Zero the page through its new VA before returning.

In addition, register the frame with the COW refcount table so a
subsequent fork() can correctly track sharing. Without this, the
child's clear_l0/clear_user_space would `frame_ref_dec` a frame
that was never `inc`'d, hit the "no refcount entry" warn path, and
miss a free.

Symmetric change in the aarch64 do_sync data-abort path and the
x86_64 #PF handler. Both gate frame_ref_inc on `feature = "fork"`
so the refcount table only exists when fork is enabled.

clear_user_space is called from sys_exec right before the new ELF
is loaded, and is supposed to leave the user side of the address
space in a "freshly created task" state. Two pieces were missing:

  1. The VMA list still contained the old image's entries
     (LOADER_START code segment, TLS region, mmap'd heap). The new
     load_application would then either tip over inserts that
     overlapped existing ranges, or — worse — the heap VMA would
     survive while its backing L0 slot had just been wiped,
     leaving the next sys_malloc to see a stale mapping.
  2. File descriptors > 2 were inherited across exec. POSIX-style
     behaviour is to keep stdin/stdout/stderr and close everything
     else; emulate that by retaining `k <= STDERR_FILENO`.

Done symmetrically on aarch64 and x86_64.
Three follow-ups to the recent VMA / fork work, two of them
load-bearing for spawn correctness on aarch64:

* aarch64: move USER_STACK into L0[USER_L0_INDEX]. It used to
  sit at 0x0840_0000_0000 - USER_STACK_SIZE (= L0[16]).
  create_new_root_page_table deep-copies only L0[USER_L0_INDEX]
  and inherits every other L0 entry verbatim, so the L1/L2/L3
  chain backing L0[16] was shared between spawner and spawnee.
  The first paging::map in the child rewrote the parent's stack
  PTEs; the parent then returned from join() with a corrupted
  return address (PC=0x1, EC=0x22). Place USER_STACK at the top
  of L0[2] instead.

* x86_64: stop padding the code allocation with USER_STACK_SIZE
  in load_application. The user stack is allocated separately
  in jump_to_user_land since the VMA split, so the addend just
  reserves and refcounts unused frames.
The kernel already gave every task its own `TaskId`, but had no
notion of "this thread belongs to that process" — `sys_getpid()`
returned the calling thread's `tid`, so every thread of a
multi-threaded program saw a different pid.

Carry a `pid` on every `Task` (common-os only):

  * Main thread of a new process (`Task::new`, `Task::new_idle`,
    `Task::new_fork`): `pid = tid`.
  * Sibling thread (`Task::new_thread`): `pid` inherited from
    the spawning thread.
  * `NewTask::thread_of` is widened from
    `Option<Arc<RootPageTable>>` to
    `Option<(Arc<RootPageTable>, ProcessId)>` so the parent's
    pid travels with the page table; `spawn_thread` captures
    it from the current task.
The syscall-return path used to execute, in order:

    swapgs    ; GS_BASE = user GS, KERNEL_GS_BASE = per-CPU
    mfence
    cli

That leaves a window between `swapgs` and `cli` where GS is
already the user-mode value but CS is still kernel. An
interrupt that hits inside this window enters the handler with
the kernel CS selector, so the swapgs guard
(`stack_frame.code_segment != SegmentSelector(8)`) decides
"already kernel, don't swap" — yet GS_BASE is the user's. Every
`gs:`-relative per-CPU access in the handler then targets the
user's GS area: garbage data at best, #GP / #PF at worst, and
sporadic because it depends on whether an IRQ lands in those
few cycles.

Reorder to `cli; mfence; swapgs;` so interrupts are masked
before the GS flip. The mfence stays between for the same
ordering reasons it did originally.
The per-CPU `user_stack` slot was the only place a task's user RSP
lived during a syscall, so any other task running a syscall while
the first was blocked (e.g. a parent in `waitpid` while its forked
child ran exec + several syscalls) clobbered it. When the blocked
task resumed, `mov rsp, gs:[user_stack]` loaded a foreign RSP and
`sysretq` faulted (PF/#UD) at a random address in some other task's
user stack.

Stash the user RSP onto the per-task kernel stack at syscall entry
(after the 14 saved registers, using `rcx` as scratch since it was
just pushed) and pop it back through the per-CPU slot under `cli`
on the return path. The per-CPU slot becomes a transient between
syscall entry and the push, plus between the pop and `sysretq` —
windows in which no other task can run.

`fork_child_start` previously reconstructed the user RSP from the
per-CPU slot too. Switch it to a per-task slot: write the user RSP
into the child's kernel stack `MARKER_SIZE` pad at `kernel_top - 8`
inside `prepare_fork_child_stack` (via `stash_user_rsp_in_child_stack`)
and load it from there before `sysretq`. Use `rsp` itself as the
scratch register for the two-step `[gs:{kernel_stack} + 8]` load so
`rax` (the child's `fork()` return value, set to 0) is preserved —
otherwise the child returned from `fork()` with a kernel pointer and
the userspace `try_into::<i32>().unwrap()` panicked.

Drop the now-unused `get_kernel_stack_top` helper.
* arch::{aarch64,x86_64}::load_application: replace the identical
  12-line `if env::is_uhyve()` / else block that installed Uhyve-
  vs. console-backed stdio with a single `fd::stdio::setup()` call,
  so both architectures share one source of truth.

* Hoist commonly-aliased imports: `size_of` is in the prelude now,
  `Arc`, `paging`, and the socket sys_* names are introduced once
  per module instead of being requalified at every call site.
  `syscalls/table.rs` shrinks accordingly.

* aarch64/mm/paging: replace `#[expect(dead_code)]` markers on
  `is_table_or_4kib_page` and `get_page_table_entry` with the real
  `#[cfg(all(feature = "common-os", feature = "fork"))]` gate that
  matches the only configuration where they are reachable. Drop
  `get_application_page_size` (no remaining callers).

* syscalls/entropy: lower the naive-fallback message from `warn!`
  to `trace!`. It fires on every syscall when the host exposes no
  entropy source and was drowning out everything else.
Route all scheduler/mm/task accesses through explicit arch::kernel
and arch::mm paths instead of the re-exported arch::core_local and
arch::* shortcuts. Split prepare_fork_child_stack (kernel) from
prepare_mem_copy_on_write (mm) at the fork call site accordingly.

Re-export prepare_fork_child_stack, BasePageSize/PageSize and
clear_user_space for both arches, and gate the common_os module on
aarch64 as well so common-os/fork builds there.
Reformat long expressions (VMA inserts, page-table walks, error
messages) to rustfmt line-wrapping, normalize indentation in
clear_user_space, and regroup/sort imports (module- vs. function-local,
std/crate blocks). Reorder pub-use reexports in arch/mod.rs and fix the
missing trailing newline. No functional change.
@stlankes stlankes marked this pull request as draft July 12, 2026 17:24

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Benchmark Results

Details
Benchmark Current: bc8fb70 Previous: 2e23902 Performance Ratio
startup_benchmark Build Time 78.52 s 80.34 s 0.98
startup_benchmark File Size 0.80 MB 0.80 MB 1.00
Startup Time - 1 core 0.73 s (±0.02 s) 0.75 s (±0.02 s) 0.98
Startup Time - 2 cores 0.75 s (±0.02 s) 0.74 s (±0.02 s) 1.03
Startup Time - 4 cores 0.76 s (±0.01 s) 0.74 s (±0.02 s) 1.02
multithreaded_benchmark Build Time 78.45 s 82.11 s 0.96
multithreaded_benchmark File Size 0.89 MB 0.86 MB 1.03
Multithreaded Pi Efficiency - 2 Threads 86.92 % (±7.01 %) 85.89 % (±6.61 %) 1.01
Multithreaded Pi Efficiency - 4 Threads 43.18 % (±2.46 %) 43.43 % (±2.56 %) 0.99
Multithreaded Pi Efficiency - 8 Threads 25.29 % (±1.43 %) 25.76 % (±1.53 %) 0.98
micro_benchmarks Build Time 75.90 s 80.40 s 0.94
micro_benchmarks File Size 0.89 MB 0.86 MB 1.03
Scheduling time - 1 thread 63.99 ticks (±2.31 ticks) 62.65 ticks (±4.06 ticks) 1.02
Scheduling time - 2 threads 36.06 ticks (±5.29 ticks) 34.08 ticks (±4.10 ticks) 1.06
Micro - Time for syscall (getpid) 4.24 ticks (±0.64 ticks) 3.45 ticks (±0.58 ticks) 1.23
Memcpy speed - (built_in) block size 4096 85453.08 MByte/s (±59163.44 MByte/s) 82448.38 MByte/s (±56997.13 MByte/s) 1.04
Memcpy speed - (built_in) block size 1048576 30651.34 MByte/s (±24654.21 MByte/s) 30585.98 MByte/s (±24707.84 MByte/s) 1.00
Memcpy speed - (built_in) block size 16777216 28879.03 MByte/s (±23723.07 MByte/s) 26340.06 MByte/s (±21720.96 MByte/s) 1.10
Memset speed - (built_in) block size 4096 85592.58 MByte/s (±59265.27 MByte/s) 82292.76 MByte/s (±56891.50 MByte/s) 1.04
Memset speed - (built_in) block size 1048576 31355.43 MByte/s (±25073.42 MByte/s) 31323.85 MByte/s (±25145.86 MByte/s) 1.00
Memset speed - (built_in) block size 16777216 29627.01 MByte/s (±24157.99 MByte/s) 27104.68 MByte/s (±22209.94 MByte/s) 1.09
Memcpy speed - (rust) block size 4096 74788.94 MByte/s (±52154.23 MByte/s) 74097.96 MByte/s (±51811.44 MByte/s) 1.01
Memcpy speed - (rust) block size 1048576 30407.01 MByte/s (±24577.31 MByte/s) 30361.60 MByte/s (±24602.37 MByte/s) 1.00
Memcpy speed - (rust) block size 16777216 28964.10 MByte/s (±23770.92 MByte/s) 27625.34 MByte/s (±22806.88 MByte/s) 1.05
Memset speed - (rust) block size 4096 75098.62 MByte/s (±52350.58 MByte/s) 74373.47 MByte/s (±51976.48 MByte/s) 1.01
Memset speed - (rust) block size 1048576 31140.42 MByte/s (±25002.30 MByte/s) 31110.89 MByte/s (±25033.24 MByte/s) 1.00
Memset speed - (rust) block size 16777216 29687.99 MByte/s (±24180.31 MByte/s) 28386.93 MByte/s (±23265.03 MByte/s) 1.05
alloc_benchmarks Build Time 75.13 s 74.76 s 1.00
alloc_benchmarks File Size 0.88 MB 0.87 MB 1.00
Allocations - Allocation success 91.31 % 91.31 % 1
Allocations - Deallocation success 100.00 % 100.00 % 1
Allocations - Pre-fail Allocations 61.44 % 61.44 % 1
Allocations - Average Allocation time 5819.46 Ticks (±373.25 Ticks) 5860.58 Ticks (±98.43 Ticks) 0.99
Allocations - Average Allocation time (no fail) 6612.43 Ticks (±321.90 Ticks) 6554.81 Ticks (±92.86 Ticks) 1.01
Allocations - Average Deallocation time 2126.09 Ticks (±250.42 Ticks) 1805.01 Ticks (±250.35 Ticks) 1.18
mutex_benchmark Build Time 75.14 s 79.82 s 0.94
mutex_benchmark File Size 0.89 MB 0.86 MB 1.03
Mutex Stress Test Average Time per Iteration - 1 Threads 12.10 ns (±0.30 ns) 12.10 ns (±0.41 ns) 1
Mutex Stress Test Average Time per Iteration - 2 Threads 40.86 ns (±2.20 ns) 40.26 ns (±1.68 ns) 1.01

This comment was automatically generated by workflow using github-action-benchmark.

@stlankes stlankes force-pushed the fork branch 3 times, most recently from 2a6e6c7 to 1592ef6 Compare July 12, 2026 19:09
jump_to_user_land takes the argument vector and the environment as
owned CStrings and places both as NULL-terminated pointer arrays on
the user stack; envp is handed over in rdx (x86_64) / x2 (aarch64).

argc, argv, and envp are bound as fixed register operands in the
inline assembly. With plain in(reg) operands, the register allocator
is free to hand rdi/rsi/rdx (x0-x2) to other operands, which the
mov sequence then clobbered before eret/iretq.

The weak symbols sys_spawn_process and sys_exec take optional
NULL-terminated argv/envp arrays; the loader provides the actual
implementation.
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.

3 participants