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
12 changes: 11 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ enable_testing()
# one. JSON and the HTTP server/client are pinned here, at the commits the
# engine used before (nlohmann/json v3.11.3, cpp-httplib v0.47.0).
include(FetchContent)
if(WIN32)
# Windows 10 or later: cpp-httplib's floor; the headers leave out min/max macros
add_compile_definitions(_WIN32_WINNT=0x0A00 WINVER=0x0A00 NOMINMAX WIN32_LEAN_AND_MEAN)
# MinGW's headers lack GetAddrInfoExCancel; the engine only ever connects to 127.0.0.1
set(HTTPLIB_USE_NON_BLOCKING_GETADDRINFO OFF CACHE BOOL "" FORCE)
if(MINGW)
# one self-contained 1bit.exe: the C++ runtime linked in, not beside it as DLLs
add_link_options(-static)
endif()
endif()
find_package(Threads REQUIRED)
set(JSON_BuildTests OFF CACHE INTERNAL "")
FetchContent_Declare(json
Expand Down Expand Up @@ -213,7 +223,7 @@ option(ONEBIT_NPU_HOST "Build the NPU host library and its tests" ${_onebit_npu_
if(ONEBIT_NPU_HOST)
find_package(PkgConfig REQUIRED)
pkg_check_modules(PCRE2 REQUIRED IMPORTED_TARGET libpcre2-8)
add_library(onebit_npu STATIC npu/full_elf.cpp npu/model.cpp npu/pack.cpp npu/private_route.cpp npu/q4nx.cpp
add_library(onebit_npu STATIC npu/file_map.cpp npu/full_elf.cpp npu/model.cpp npu/pack.cpp npu/private_route.cpp npu/q4nx.cpp
npu/tokenizer.cpp)
target_include_directories(onebit_npu PUBLIC npu)
target_link_libraries(onebit_npu PUBLIC nlohmann_json::nlohmann_json PRIVATE PkgConfig::PCRE2)
Expand Down
32 changes: 32 additions & 0 deletions app/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,13 @@
#include <vector>

#include <cerrno>
#ifdef _WIN32
#include <process.h>
#else
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#endif

namespace {

Expand Down Expand Up @@ -140,6 +144,33 @@ int run_npu(int argc, char** argv) {
// then comfyui_cpp on PATH. It is opened once, checked through that descriptor (a regular,
// executable file that other users cannot write) and run with fexecve, so the file that was
// checked is the file that runs.
#ifdef _WIN32
// Windows: $ONEBIT_COMFYUI (an absolute path), then this build's, then comfyui_cpp.exe on PATH;
// run as a child that 1bit waits for (no exec in place on Windows).
int run_comfy(int argc, char** argv) {
if (argc < 1 || !std::strcmp(argv[0], "-h") || !std::strcmp(argv[0], "--help")) {
std::printf("usage: 1bit comfy <workflow.json>\n"
" runs a ComfyUI API-format workflow (SD1.5 txt2img/img2img, see docs/comfyui.md)\n");
return argc < 1 ? 2 : 0;
}
std::string bin;
if (const char* e = std::getenv("ONEBIT_COMFYUI"); e && *e) {
if (!std::filesystem::path(e).is_absolute())
throw std::runtime_error("ONEBIT_COMFYUI must be an absolute path, not " + std::string(e));
bin = e;
}
#ifdef ONEBIT_COMFYUI_BIN
if (bin.empty() && std::filesystem::exists(ONEBIT_COMFYUI_BIN)) bin = ONEBIT_COMFYUI_BIN;
#endif
if (bin.empty()) bin = "comfyui_cpp.exe"; // _spawnvp searches PATH
std::vector<const char*> args{bin.c_str()};
for (int i = 0; i < argc; ++i) args.push_back(argv[i]);
args.push_back(nullptr);
const intptr_t rc = ::_spawnvp(_P_WAIT, bin.c_str(), args.data());
if (rc < 0) throw std::runtime_error("cannot run " + bin + ": " + std::strerror(errno));
return int(rc);
}
#else
std::string find_comfy() {
if (const char* e = std::getenv("ONEBIT_COMFYUI"); e && *e) {
if (e[0] != '/') throw std::runtime_error("ONEBIT_COMFYUI must be an absolute path, not " + std::string(e));
Expand Down Expand Up @@ -187,6 +218,7 @@ int run_comfy(int argc, char** argv) {
::close(fd);
throw std::runtime_error("cannot run " + bin + ": " + std::strerror(err));
}
#endif
#ifdef ONEBIT_LAYA
// One request in, one device out: runs the Laya scorer's fixed routing question
// against the request state and prints the winning device (npu|hrx|vulkan|zinc).
Expand Down
123 changes: 112 additions & 11 deletions app/serve.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -73,21 +73,29 @@
#include <filesystem>
#include <memory>
#include <mutex>
#if defined(__linux__)
#include <sys/prctl.h>
#else
#include <spawn.h>
#endif
#include <stdexcept>
#include <string>
#include <thread>
#include <vector>
#if defined(_WIN32)
// httplib.h brought in winsock2.h and windows.h
#elif defined(__linux__)
#include <netinet/in.h>
#include <sys/prctl.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <unistd.h>
#else
#include <netinet/in.h>
#include <thread>
#include <spawn.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <unistd.h>
#include <vector>
#endif

#ifndef _WIN32
extern char** environ;
#endif

namespace onebit {

Expand Down Expand Up @@ -185,23 +193,106 @@ std::string default_mlx() {
}

int free_port() {
#ifdef _WIN32
// Winsock is started by httplib.h (its WSInit)
const SOCKET s = ::socket(AF_INET, SOCK_STREAM, 0);
const bool bad_socket = s == INVALID_SOCKET;
auto close_socket = [&] { ::closesocket(s); };
int len = sizeof(sockaddr_in);
#else
const int s = ::socket(AF_INET, SOCK_STREAM, 0);
const bool bad_socket = s < 0;
auto close_socket = [&] { ::close(s); };
socklen_t len = sizeof(sockaddr_in);
#endif
sockaddr_in a{};
a.sin_family = AF_INET;
a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
a.sin_port = 0;
socklen_t len = sizeof(a);
if (s < 0 || ::bind(s, reinterpret_cast<sockaddr*>(&a), sizeof(a)) != 0 ||
if (bad_socket || ::bind(s, reinterpret_cast<sockaddr*>(&a), sizeof(a)) != 0 ||
::getsockname(s, reinterpret_cast<sockaddr*>(&a), &len) != 0) {
if (s >= 0) ::close(s);
if (!bad_socket) close_socket();
throw std::runtime_error("cannot find a free loopback port");
}
const int port = ntohs(a.sin_port);
::close(s);
close_socket();
return port;
}

// A child OpenAI-compatible server on a loopback port.
#ifdef _WIN32
// Windows: CreateProcess into a job object that kills its processes when its last handle
// closes, so the backend dies with `1bit serve` for any reason (Linux: PR_SET_PDEATHSIG).
class Child {
public:
Child(const std::vector<std::string>& argv, const std::vector<std::string>& env_extra, int port)
: port_(port) {
// the command line, each argument quoted the way CommandLineToArgvW reads it back
std::string cmd;
for (const auto& a : argv) {
if (!cmd.empty()) cmd += ' ';
cmd += '"';
size_t bs = 0;
for (char ch : a) {
if (ch == '\\') { bs++; continue; }
if (ch == '"') cmd.append(bs * 2 + 1, '\\');
else cmd.append(bs, '\\');
bs = 0;
cmd += ch;
}
cmd.append(bs * 2, '\\');
cmd += '"';
}
// our environment plus env_extra (a value the user set wins), as a double-NUL block
std::vector<std::string> env_store;
if (char* block = ::GetEnvironmentStringsA()) {
for (char* e = block; *e; e += std::strlen(e) + 1) env_store.emplace_back(e);
::FreeEnvironmentStringsA(block);
}
for (const auto& e : env_extra) {
const std::string key = e.substr(0, e.find('=') + 1);
bool present = false;
for (const auto& have : env_store) present = present || have.rfind(key, 0) == 0;
if (!present) env_store.push_back(e);
}
std::string env_block;
for (const auto& e : env_store) { env_block += e; env_block += '\0'; }
env_block += '\0';
job_ = ::CreateJobObjectA(nullptr, nullptr);
if (job_) {
JOBOBJECT_EXTENDED_LIMIT_INFORMATION li{};
li.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
::SetInformationJobObject(job_, JobObjectExtendedLimitInformation, &li, sizeof li);
}
STARTUPINFOA si{};
si.cb = sizeof si;
PROCESS_INFORMATION pi{};
if (!::CreateProcessA(nullptr, cmd.data(), nullptr, nullptr, FALSE, CREATE_SUSPENDED, env_block.data(), nullptr,
&si, &pi))
throw std::runtime_error("cannot start " + argv[0] + " (error " + std::to_string(::GetLastError()) + ")");
if (job_) ::AssignProcessToJobObject(job_, pi.hProcess); // before it runs: its children join too
::ResumeThread(pi.hThread);
::CloseHandle(pi.hThread);
proc_ = pi.hProcess;
}
~Child() {
stop();
if (job_) ::CloseHandle(job_);
}
Child(const Child&) = delete;
Child& operator=(const Child&) = delete;

void stop() {
if (!proc_) return;
::TerminateProcess(proc_, 1); // no SIGTERM on Windows; llama-server keeps no state to flush
::WaitForSingleObject(proc_, 5000);
::CloseHandle(proc_);
proc_ = nullptr;
}

bool alive() const { return proc_ && ::WaitForSingleObject(proc_, 0) == WAIT_TIMEOUT; }

#else
class Child {
public:
Child(const std::vector<std::string>& argv, const std::vector<std::string>& env_extra, int port)
Expand Down Expand Up @@ -256,6 +347,7 @@ class Child {
}

bool alive() const { return pid_ > 0 && ::waitpid(pid_, nullptr, WNOHANG) == 0; }
#endif

// Polls the child's /health until it answers 200, it exits, or time runs out.
bool wait_ready(std::chrono::seconds timeout, const std::atomic<bool>& stop) const {
Expand All @@ -273,7 +365,11 @@ class Child {
int port() const { return port_; }

private:
#ifdef _WIN32
HANDLE proc_ = nullptr, job_ = nullptr;
#else
pid_t pid_ = -1;
#endif
int port_;
};

Expand Down Expand Up @@ -736,10 +832,15 @@ int serve_child(const Options& o) {
~Stop() { s.stop(); if (t.joinable()) t.join(); }
} stop{srv, listener};

#ifdef _WIN32
std::signal(SIGTERM, on_stop_signal);
std::signal(SIGINT, on_stop_signal);
#else
struct sigaction sa{};
sa.sa_handler = on_stop_signal;
::sigaction(SIGTERM, &sa, nullptr);
::sigaction(SIGINT, &sa, nullptr);
#endif
for (const auto& b : laya ? std::vector<Launch>{} : backends) {
std::fprintf(stderr, "1bit serve: %s on %s (%s)\n", id.c_str(), b.device.c_str(), b.argv[0].c_str());
children.push_back(std::make_unique<Child>(b.argv, b.env, b.port));
Expand Down
66 changes: 66 additions & 0 deletions docs/windows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<!--
Copyright 2026 bong-water-water-bong
SPDX-License-Identifier: Apache-2.0

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->

# Windows

`1bit.exe` runs on Windows 10 and later (x64): `1bit serve` with the Vulkan backend, `1bit route` (Laya) and `1bit comfy`. It is cross-built on Linux. There is no NPU lane, HRX, ROCm or ZINC on Windows yet.

```sh
scripts/build-windows.sh <out-dir> # ~2 min on Strix Halo once the downloads are cached
```

The script writes two single, static executables:
- **`<out-dir>/1bit.exe`** (3.2 MB). It imports only `KERNEL32`, `WS2_32` and the UCRT that ships with Windows 10.
- **`<out-dir>/llama-server.exe`**, the Vulkan pin (`third_party/llama.cpp-vulkan`) built for Windows. It also imports `vulkan-1.dll`, from the GPU driver.

Keep the two side by side: `1bit.exe serve -m model.gguf` finds `llama-server.exe` next to itself. `--llama-server PATH` or `ONEBIT_LLAMA_SERVER` names another one.

## What it downloads

Every download is pinned and checked against its sha256:

| input | why |
| --- | --- |
| llvm-mingw 20260922 (clang 23, UCRT) | the engine is C++26; distribution MinGW GCC 13 is too old |
| PCRE2 10.48 | the tokenizer, linked statically |
| Vulkan-Headers `vulkan-sdk-1.4.357.0` | llama.cpp's Vulkan backend |
| the Vulkan loader's `vulkan-1.def`, same tag | the import library, made with `llvm-dlltool`: no Vulkan SDK or Windows machine needed |
| SPIRV-Headers, same tag | llama.cpp's Vulkan backend |

Host tools: `cmake`, `git`, `curl`, a native C/C++ compiler (llama.cpp's shader generator runs on the build machine) and `glslc`.

## What changed for Windows

- **Backends.** `1bit serve` starts its backends with `CreateProcess`, inside a job object that kills them when its last handle closes. So a backend dies with `1bit.exe` for any reason, as `PR_SET_PDEATHSIG` does on Linux; the Linux and macOS paths are unchanged. Windows has no SIGTERM, so a backend stops with `TerminateProcess`.
- **Model files.** Model and safetensors files are mapped through `npu/file_map.{h,cpp}` (mmap on POSIX, `CreateFileMapping` on Windows).
- **cpp-httplib.** It is built at the Windows 10 API level, with its non-blocking DNS lookup off, because MinGW's headers lack `GetAddrInfoExCancel` and the engine only ever connects to 127.0.0.1.
- **`1bit comfy`.** It spawns `comfyui_cpp.exe` and waits for it, since there is no exec in place on Windows.
- **llama.cpp workaround.** The pinned llama.cpp uses `std::function` in `ggml-vulkan-types.h` without including `<functional>`. libc++ does not include it transitively, so the script adds it from the command line.

## Tested

On Strix Halo under Wine 11, whose Vulkan goes to the host's RADV driver, so on the real Radeon 8060S. Measured 2026-09-25:
- `1bit.exe serve -m Qwen3-0.6B-Q4_K_M.gguf` found `llama-server.exe` beside it, answered "The capital of France is Paris." (132 tok/s) and "Three colors: green, red, and blue.", and streamed a reply as SSE.
- `taskkill /F /IM 1bit.exe` left no `llama-server.exe` behind.
- The Linux build of the same sources passes its tests.

It has not run on a real Windows machine yet.

## Next

- **An ONNX Runtime GenAI backend:** Lemonade's ONNX and hybrid models, and the Ryzen AI NPU on Windows through the Vitis AI execution provider.
- **The NPU lane on Windows XRT.**
25 changes: 5 additions & 20 deletions laya/safetensors.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,7 @@

#include <cmath>
#include <cstring>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#include "file_map.h"

namespace onebit::laya {

Expand Down Expand Up @@ -50,21 +47,9 @@ inline float bf16_to_f32(uint16_t bf) {

bool Safetensors::open(const std::string& path) {
err_.clear();
const int fd = ::open(path.c_str(), O_RDONLY);
if (fd < 0) {
err_ = "cannot open " + path;
return false;
}
struct stat st {};
::fstat(fd, &st);
size_ = size_t(st.st_size);
void* m = ::mmap(nullptr, size_, PROT_READ, MAP_PRIVATE, fd, 0);
::close(fd);
if (m == MAP_FAILED) {
err_ = "cannot map " + path;
return false;
}
map_ = static_cast<uint8_t*>(m);
const uint8_t* m = onebit::npu::map_file(path, size_, &err_);
if (!m) return false;
map_ = const_cast<uint8_t*>(m);

uint64_t header = 0;
if (size_ < 8) {
Expand Down Expand Up @@ -96,7 +81,7 @@ bool Safetensors::open(const std::string& path) {
}

Safetensors::~Safetensors() {
if (map_) ::munmap(map_, size_);
onebit::npu::unmap_file(map_, size_);
}

const Tensor* Safetensors::find(const std::string& name) const {
Expand Down
Loading
Loading