Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
branch = plat-hyperlight-v2
[submodule "kernel/app-elfloader"]
path = kernel/app-elfloader
# The fork, for three fixes: arm64 AT_HWCAP (upstream has it in another
# The fork, for four fixes: arm64 AT_HWCAP (upstream has it in another
# form, which needs a Unikraft change our plat-hyperlight-v2 kernel
# lacks), AT_RANDOM on the program's stack, and empty argument and
# environment strings, until they are upstream.
# lacks), AT_RANDOM and AT_EXECFN on the program's stack, and empty
# argument and environment strings, until they are upstream.
url = https://github.com/danbugs/app-elfloader.git
branch = plat-hyperlight-v2
[submodule "kernel/libs/libelf"]
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Prerelease] - Unreleased

### Fixed

- Memory a program mapped with `mmap()` stayed mapped after its process ended, so a guest that ran child processes again and again (Azure CLI running Bicep) ran out of memory. The kernel now unmaps what a process mapped when it exits, is killed or execs; memory shared with `CLONE_VM` stays until the last process sharing it ends. In the `java` image, a JVM started after `System.exit()` gets the heap the first had, and restarts are no longer refused once the old JVMs' memory runs out.
- `munmap()` and `mprotect()` of a range that began in a 2 MiB block with nothing mapped yet missed pages past the block's end: they stayed mapped, with their memory, which a mapping made there later read, or kept their protection.
- `mremap()` failed with `ENOSYS`, so musl took the main thread's stack for a single page (`pthread_getattr_np()`), and .NET's `EnsureSufficientExecutionStack` failed. A mapping now shrinks, or grows into free space after it, in place; `MREMAP_MAYMOVE` resizes in place too and fails with `ENOMEM` where only a move would do, and `realloc()` then copies.
- Static programs linked against a glibc built for x86-64-v3, as Ubuntu 26.04's is, died with `SIGILL` before `main()`: the guest never turned AVX on, and that glibc uses AVX2 without checking. The kernel now enables AVX the first time a program uses it, and AVX-512 the first time one uses that, and turns them back on after restoring a snapshot taken since. Until then CPUID reports OSXSAVE clear, so programs that check for AVX properly keep to SSE: with AVX on, every VM exit costs more on nested AMD hosts (agent cold-snap ~33 → ~48 ms on GitHub's runners). Restoring a snapshot taken before turns AVX off again.

## [v0.19.1]

### Added
Expand Down
2 changes: 1 addition & 1 deletion benchmarks/limits.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"windows-arm64": { "cold/hello": 5700, "cold-snap/hello": 160, "warm-restore/hello": 60, "restore-cost/hello": 57, "warm-stateful/hello": 3, "parallel-exec/hello": 24, "snapshot-size/hello": 140, "cold-snap/mount": 240, "warm-restore/mount": 120, "restore-cost/mount": 73 }
},
"agent": {
"linux": { "cold/hello": 16000, "cold-snap/hello": 25, "warm-restore/hello": 20, "restore-cost/hello": 4, "warm-stateful/hello": 0.4, "parallel-exec/hello": 30, "snapshot-size/hello": 1000, "cold-snap/mount": 38, "warm-restore/mount": 30, "restore-cost/mount": 6 },
"linux": { "cold/hello": 16000, "cold-snap/hello": 30, "warm-restore/hello": 20, "restore-cost/hello": 4, "warm-stateful/hello": 0.4, "parallel-exec/hello": 30, "snapshot-size/hello": 1000, "cold-snap/mount": 38, "warm-restore/mount": 30, "restore-cost/mount": 6 },
"windows": { "cold/hello": 45000, "cold-snap/hello": 250, "warm-restore/hello": 65, "restore-cost/hello": 150, "warm-stateful/hello": 1.5, "parallel-exec/hello": 110, "snapshot-size/hello": 1000, "cold-snap/mount": 375, "warm-restore/mount": 85, "restore-cost/mount": 225 },
"macos-arm64": { "cold/hello": 7800, "cold-snap/hello": 15, "warm-restore/hello": 10, "restore-cost/hello": 42, "warm-stateful/hello": 1, "parallel-exec/hello": 25, "snapshot-size/hello": 1000, "cold-snap/mount": 18, "warm-restore/mount": 10, "restore-cost/mount": 42 },
"windows-arm64": { "cold/hello": 54000, "cold-snap/hello": 600, "warm-restore/hello": 210, "restore-cost/hello": 200, "warm-stateful/hello": 3, "parallel-exec/hello": 95, "snapshot-size/hello": 1000, "cold-snap/mount": 730, "warm-restore/mount": 230, "restore-cost/mount": 230 }
Expand Down
4 changes: 2 additions & 2 deletions conformance/python/known_failures.toml
Original file line number Diff line number Diff line change
Expand Up @@ -209,11 +209,11 @@ modules = [

[may-fix.posix]
reason = "Incomplete POSIX syscall implementation"
todo = "Implement missing syscalls (fcntl locking, ioctl, mremap) in Unikraft"
todo = "Implement missing syscalls (fcntl locking, ioctl) and MAP_SHARED file mappings in Unikraft"
modules = [
"test_fcntl", # FAIL — file locking (fcntl/flock)
"test_ioctl", # ERROR — ioctl operations
"test_mmap", # FAIL — mremap() is a non-functional stub
"test_mmap", # ERROR — MAP_SHARED file mappings (EOPNOTSUPP)
"test_re", # FAIL — regex edge cases (likely locale-dependent)
]

Expand Down
2 changes: 1 addition & 1 deletion docs/driver.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ sequenceDiagram

`boot()` returns when the driver is parked in its first `read()`, so every call the host ever makes finds a reader waiting. Two things complete a call: the callback returning, and the driver reading again. The kernel notes the return at that second read and reports it with the entry's `Yield`, which is why the driver never says "done" explicitly. When the callback returns is each runtime's own rule: Node when its event loop drains, so a live timer or server keeps the call open until it is cleared or `unref()`ed; an exec'd program when it exits, taking its threads with it as any process does; PHP when its script returns, leaving a fiber it suspended for a later call to resume; Python, .NET and Java when the main code returns, so a thread it started, daemon or not, foreground or background, is left behind and makes progress on the steps that follow like any blocked guest thread. Python, .NET and Java differ from their standalone programs here, which wait for non-daemon and foreground threads before exiting; a long-lived interpreter cannot run the exit-time machinery that makes that safe (a module-level thread pool would hold the call forever), so the call is the main code. What the runtime prints to stdout and stderr is the guest's output on the host, kept apart: `hluk run` writes each to its own stream, and `drain_stderr()` returns standard error alone.

An exit inside a call (`sys.exit()`, `process.exit()`, PHP's `exit()`, `Environment.Exit()`, `System.exit()`, a shell's `exit`, a program's exit code) ends the call with that status, as it would end a script. The runtime is there for the next call: Python, Node and PHP catch the exit and keep their state; .NET, Java and bash cannot, so the next call starts a fresh one. The kernel keeps an exited process's memory, so each fresh .NET or Java runtime needs the room for another; `hl_javadriver` measures what its first JVM took and fails a call that would start one without twice that free, and sizes the new one's heap from what is left after it (two restarts at java's 512 MiB), which a restore from a snapshot undoes.
An exit inside a call (`sys.exit()`, `process.exit()`, PHP's `exit()`, `Environment.Exit()`, `System.exit()`, a shell's `exit`, a program's exit code) ends the call with that status, as it would end a script. The runtime is there for the next call: Python, Node and PHP catch the exit and keep their state; .NET, Java and bash cannot, so the next call starts a fresh one. An exited process's memory goes back to the guest, so a fresh .NET or Java runtime starts in the memory the old one had; `hl_javadriver` sizes each JVM's heap from the memory free when it starts, and fails a call that would start one with less than twice what its first JVM took free (processes the code left running still hold theirs).

A call can block. `run("time.sleep(2)")` puts the driver thread to sleep, and to the kernel that is an ordinary blocked thread: with nothing else to run it yields, the host waits the two seconds without using CPU, re-enters, and the call goes on. A `recv()` on a socket does the same, with the host waiting for the socket instead. That is the step model of [execution.md](execution.md) seen from inside the driver.

Expand Down
3 changes: 1 addition & 2 deletions drivers/dotnet-jit/hl_dotnetdriver.c
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,7 @@
* compiles to its own assembly, which stays loaded, statics and all.
* Environment.Exit() ends the process, which is the runtime: the call
* ends with that exit status, and the next call starts a fresh one,
* warm-up included. Until the kernel returns an exited process's
* memory, that fresh one needs the room for a second runtime.
* warm-up included, in the memory the old one gave back.
*/

#include <signal.h>
Expand Down
37 changes: 16 additions & 21 deletions drivers/java/hl_javadriver.c
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,8 @@
*
* The JVM stays alive across dispatches. System.exit() ends it, which
* is the runtime: the call ends with that exit status, and the next call
* starts a fresh one, warm-up included, while the memory allows: until
* the kernel returns an exited process's memory, each one needs the room
* for another runtime.
* starts a fresh one, warm-up included, in the memory the old one gave
* back.
*/

#include <signal.h>
Expand Down Expand Up @@ -62,9 +61,9 @@ static int g_pipe_from_java = -1; /* parent reads the status here */
* call, as it does for the first JVM. */
static char **g_java_envp;

/* What the boot JVM took, warm-up included, in MiB: the kernel does not
* return an exited process's memory, so each JVM after System.exit() takes
* that much more, and one started without the room crashes the guest. */
/* What the boot JVM took, warm-up included, in MiB: a JVM started after
* System.exit() needs as much, and one started without the room (the code
* left processes behind that hold it) crashes the guest. */
static unsigned long g_java_mb;
extern char **environ;

Expand Down Expand Up @@ -169,17 +168,12 @@ static int java_spawn(void)
snprintf(fd_in_str, sizeof(fd_in_str), "%d", pipe_code[0]);
snprintf(fd_out_str, sizeof(fd_out_str), "%d", pipe_ack[1]);
/* The heap is a share (jvm.args) of what the JVM takes for the guest's
* memory: what is free, not the total, which a JVM started after
* System.exit() shares with the ones before it, whose memory the
* kernel keeps. Sized from the total, its heap would outgrow the
* memory left, and an allocation fault the guest instead of throwing
* OutOfMemoryError. */
unsigned long free_mb = java_free_mb();

/* A JVM after the first also needs room for itself, what the first
* took: its heap's share is of the rest. */
snprintf(max_ram, sizeof(max_ram), "-XX:MaxRAM=%luM",
g_java_mb && free_mb > g_java_mb ? free_mb - g_java_mb : free_mb);
* memory: what is free, not the total, which the driver and whatever
* the code left running share. Sized from the total, its heap would
* outgrow the memory left, and an allocation fault the guest instead
* of throwing OutOfMemoryError. A JVM after System.exit() gets the
* memory of the one before it back, so this is what the first had. */
snprintf(max_ram, sizeof(max_ram), "-XX:MaxRAM=%luM", java_free_mb());
memcpy(argv, java_argv, sizeof(argv));
argv[2] = max_ram;
argv[JAVA_ARGC] = fd_in_str;
Expand Down Expand Up @@ -286,14 +280,15 @@ static int java_dispatch(const uint8_t *fc, size_t fc_len)
/* The call after one that ended the child starts a new one. */
if (g_java_pid < 0) {
/* Twice what the first took: room for the JVM, and a heap of
* half that (java_spawn). */
* half that (java_spawn). The old JVM's memory is back,
* unless processes it started still hold it. */
unsigned long free_mb = java_free_mb();

if (free_mb < g_java_mb * 2) {
fprintf(stderr, "hl_javadriver: %lu MiB left, too little for a fresh JVM "
"(the first took %lu): an exited JVM's memory is not given "
"back, so restore the sandbox from a snapshot or give it "
"more memory\n", free_mb, g_java_mb);
"(the first took %lu): processes the code started may "
"still hold the memory, so restore the sandbox from a "
"snapshot or give it more memory\n", free_mb, g_java_mb);
fflush(stderr);
goto out;
}
Expand Down
4 changes: 2 additions & 2 deletions examples/agent-framework/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,13 @@ Agent: A micro-VM is a lightweight virtual machine that boots in milliseconds

Why the build looks the way it does:

- **SSE-only llama.cpp.** The guest CPU exposes SSE/SSE2/SSE4 but **not AVX/AVX2**. The prebuilt `llama-cpp-python` wheels assume AVX2 and crash with an illegal instruction, so the `Dockerfile` compiles llama.cpp from source with `-DGGML_AVX=OFF -DGGML_AVX2=OFF -DGGML_FMA=OFF -DGGML_F16C=OFF`.
- **SSE-only llama.cpp.** The prebuilt `llama-cpp-python` wheels assume AVX2, which the guest did not enable when this example was written (the kernel now enables AVX the first time a program uses it; the example has not been moved over), so the `Dockerfile` compiles llama.cpp from source with `-DGGML_AVX=OFF -DGGML_AVX2=OFF -DGGML_FMA=OFF -DGGML_F16C=OFF`.
- **OpenMP off** (`-DGGML_OPENMP=OFF`) to avoid a libgomp runtime dependency; ggml falls back to pthreads.
- **Small buffers, no mmap.** `n_ctx=512`, `n_batch=64`, and `use_mmap=False` keep the compute buffers inside the identity-mapped guest memory.

The rootfs is ~540 MB (it bakes in the ~380 MB model), and the whole cpio is extracted into the guest ramfs on boot; `use_mmap=False` then reads the model into a second buffer. So the guest needs ~1.5 GB of scratch — `--scratch-mb 1536` is the floor here (1024/1280 fail to even extract the rootfs); more is fine.

Generation is a few tokens/sec because the guest is a **single vCPU** and the model runs **SSE-only** (the guest CPU exposes no AVX/AVX2, so llama.cpp is compiled without them) with OpenMP off. It's scalar inference on one core; that's a guest-capability limit, not a config to tune.
Generation is a few tokens/sec because the guest is a **single vCPU** and the model runs **SSE-only** (llama.cpp is compiled without AVX/AVX2) with OpenMP off. It's scalar inference on one core.
- **stdlib stubs.** `local.py` registers no-op `multiprocessing`/`sqlite3` stubs that `llama-cpp-python` imports transitively but this example never uses; they are ignored when the real modules are present.

Both rootfses extend `python-shell` (build it first with `just build-rootfs python-shell`). The local build compiles llama.cpp and downloads a ~400 MB model, so it takes several minutes.
Loading
Loading