diff --git a/.gitignore b/.gitignore index e665939..5f76e4e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ # Compiled binaries flux +iris +iris.exe # Model weights (large files) flux-klein-*/ diff --git a/AGENT.md b/AGENT.md index 6e7b477..090e865 100644 --- a/AGENT.md +++ b/AGENT.md @@ -64,9 +64,79 @@ main.c - CLI entry point This project implements three targets: - MPS: Apple Silicon GPU path. -- BLAS: optimized CPU inference via BLAS/OpenBLAS. +- BLAS: optimized CPU inference via BLAS/OpenBLAS. On Windows (MSYS2 + UCRT64) this is the only supported target; it builds without the + interactive REPL. - generic: pure C fallback, very slow. +# Windows Notes + +Build from an MSYS2 UCRT64 shell: + + pacman -S mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-openblas make + make blas + +Platform shims, all guarded by `#ifdef _WIN32`: +- `iris_safetensors.c`: file mapping via CreateFileMapping/MapViewOfFile + instead of mmap. The view keeps its own references, so the file and + mapping handles are closed right after mapping, mirroring the POSIX + code that closes the fd after mmap. Release goes through the + `iris_unmap()` helper. +- `iris_transformer_flux.c`: GetSystemInfo instead of sysconf for the CPU + count. MinGW ships unistd.h but not sysconf. +- `terminals.c`: the iTerm2 temp-file path returns -1. MinGW has no + mkstemps(), and iTerm2 is never detected on Windows anyway. +- Config, tokenizer and index files are opened in binary mode ("rb"): + text mode would translate CRLF and break sized reads. + +Not built on Windows: the interactive REPL (`iris_cli.c`, `linenoise.c`, +`embcache.c` need termios). Requesting interactive mode prints an error +and exits. Terminal image previews do not render. + +Note that `make pngtest` still fails on its first image pair: upstream +compares `woman_with_sunglasses.png` (512x512) against +`woman_with_sunglasses_compressed2.png` (256x256). The `cat_*` pair is +correctly matched and passes. + +## Windows performance + +Measured on a Xeon W-2125 (4 cores / 8 threads, 32 GB), flux-klein-4b, +512x512 at 4 steps. **Use the mmap default and cap BLAS at one thread per +physical core** -- that combination is 2.7x faster than the opposite one: + + ./iris.exe -d flux-klein-4b --blas-threads 4 -p "..." -o out.png + +| Config | 512x512 | 256x256 | +|---------------------------|---------|---------| +| mmap + 4 threads | 219s | 138s | +| --no-mmap + 4 threads | | 238s | +| --no-mmap + 8 threads | 592s | 293s | + +Two results worth keeping: +- **`--no-mmap` is slower here**, unlike what the README suggests for + machines with spare RAM. Copying ~15 GB of weights into RAM costs 26s + for the transformer alone and doubles text encoding; a single + generation never earns that back. It may still pay off in a long + interactive session, which this build does not have. +- **8 threads lose to 4.** The 4 physical cores already saturate the + vector units, so hyperthreading only adds contention with the + head-parallel attention threads. + +Neither switch changes the output: mmap vs --no-mmap and 4 vs 8 threads +produce bit-identical images (mean_diff 0.000000). + +## Windows test results + +`run_test.py` cannot be used as-is on hardware this slow: it kills each +test after 300s, and the 512x512 case needs more. The three cases were +run by hand instead, all passing well inside the threshold of 20: + +| Test | mean_diff | upstream expects | +|-------------------------------|-----------|------------------| +| 64x64, 2 steps, seed 42 | 1.14 | ~3.4 | +| 512x512, 4 steps, seed 123 | 2.12 | ~1.7 | +| img2img 256x256, seed 456 | 8.29 | 6-17 | + # Development Rules - No additional project dependencies. Acceptable external deps are BLAS/OpenBLAS and Metal/MPS from macOS. @@ -91,6 +161,10 @@ Z-Image example: ./iris -d zimage-turbo -p "a fish" -o /tmp/zimage.png +On Windows the binary is `iris.exe`: + + ./iris.exe -d flux-klein-4b -p "a cat and a dog playing" -o test.png + If model weights are missing, use the download script only after user approval. # Python Reference Implementations diff --git a/Makefile b/Makefile index b46cec1..4a9445c 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,12 @@ UNAME_M := $(shell uname -m) # Source files SRCS = iris.c iris_kernels.c iris_tokenizer.c iris_vae.c iris_transformer_flux.c iris_transformer_zimage.c iris_sample.c iris_image.c jpeg.c iris_safetensors.c iris_qwen3.c iris_qwen3_tokenizer.c terminals.c OBJS = $(SRCS:.c=.o) +# The interactive REPL needs termios/linenoise: not available on Windows. +ifneq (,$(filter MINGW% MSYS%,$(UNAME_S))) +CLI_SRCS = +else CLI_SRCS = iris_cli.c linenoise.c embcache.c +endif CLI_OBJS = $(CLI_SRCS:.c=.o) MAIN = main.c TARGET = iris @@ -65,9 +70,15 @@ ifeq ($(UNAME_S),Darwin) blas: CFLAGS = $(CFLAGS_BASE) -DUSE_BLAS -DACCELERATE_NEW_LAPACK blas: LDFLAGS += -framework Accelerate else +ifneq (,$(filter MINGW% MSYS%,$(UNAME_S))) +MINGW_PREFIX ?= /ucrt64 +blas: CFLAGS = $(CFLAGS_BASE) -DUSE_BLAS -DUSE_OPENBLAS -DIRIS_NO_REPL -I$(MINGW_PREFIX)/include/openblas +blas: LDFLAGS += -lopenblas +else blas: CFLAGS = $(CFLAGS_BASE) -DUSE_BLAS -DUSE_OPENBLAS -I/usr/include/openblas blas: LDFLAGS += -lopenblas endif +endif blas: clean $(TARGET) @echo "" @echo "Built with BLAS backend (~30x faster than generic)" @@ -139,10 +150,10 @@ test-quick: pngtest: @echo "Running PNG compression compare test..." - @$(CC) $(CFLAGS_BASE) -I. png_compare.c iris_image.c -lm -o /tmp/iris_png_compare + @$(CC) $(CFLAGS_BASE) -I. png_compare.c iris_image.c jpeg.c -lm -o /tmp/iris_png_compare @/tmp/iris_png_compare images/woman_with_sunglasses.png images/woman_with_sunglasses_compressed2.png @/tmp/iris_png_compare images/cat_uncompressed.png images/cat_compressed.png - @rm -f /tmp/iris_png_compare + @rm -f /tmp/iris_png_compare /tmp/iris_png_compare.exe @echo "PNG TEST PASSED" install: $(TARGET) $(LIB) @@ -169,9 +180,13 @@ ifeq ($(UNAME_S),Darwin) ifeq ($(UNAME_M),arm64) @echo " mps - Metal GPU (recommended)" endif +else +ifneq (,$(filter MINGW% MSYS%,$(UNAME_S))) + @echo " blas - OpenBLAS (MSYS2 UCRT64; REPL not built)" else @echo " blas - OpenBLAS (requires libopenblas-dev)" endif +endif # ============================================================================= # Dependencies diff --git a/README.md b/README.md index cb27ffc..1734898 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Supported model families: ```bash # Build (choose your backend) make mps # Apple Silicon (fastest) -# or: make blas # Intel Mac / Linux with OpenBLAS +# or: make blas # Intel Mac / Linux / Windows (MSYS2) with OpenBLAS # or: make generic # Pure C, no dependencies # Download a model (~16GB) - pick one: @@ -281,6 +281,7 @@ make mps # Apple Silicon Metal GPU (fastest, macOS only) - macOS Intel: `make blas` - Linux with OpenBLAS: `make blas` - Linux without OpenBLAS: `make generic` +- Windows: `make blas` from an MSYS2 UCRT64 shell For `make blas` on Linux, install OpenBLAS first: ```bash @@ -291,6 +292,17 @@ sudo apt install libopenblas-dev sudo dnf install openblas-devel ``` +On Windows, build from an [MSYS2](https://www.msys2.org/) **UCRT64** shell: +```bash +pacman -S mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-openblas make +make blas +``` + +The resulting `iris.exe` needs the UCRT64 DLLs, so run it from that shell +or copy the DLLs next to the binary. The interactive REPL is not built on +Windows (linenoise needs termios), and terminal image previews do not +render there. + Other targets: ```bash make clean # Clean build artifacts diff --git a/iris.c b/iris.c index 310a773..97149f6 100644 --- a/iris.c +++ b/iris.c @@ -260,7 +260,7 @@ iris_ctx *iris_load_dir(const char *model_dir) { ctx->is_zimage = 0; snprintf(path, sizeof(path), "%s/model_index.json", model_dir); if (file_exists(path)) { - FILE *f = fopen(path, "r"); + FILE *f = fopen(path, "rb"); if (f) { char buf[4096]; size_t n = fread(buf, 1, sizeof(buf) - 1, f); @@ -284,7 +284,7 @@ iris_ctx *iris_load_dir(const char *model_dir) { ctx->text_dim = 7680; /* default 4B: 3 * 2560 */ snprintf(path, sizeof(path), "%s/transformer/config.json", model_dir); if (file_exists(path)) { - FILE *f = fopen(path, "r"); + FILE *f = fopen(path, "rb"); if (f) { char buf[8192]; size_t n = fread(buf, 1, sizeof(buf) - 1, f); @@ -390,7 +390,7 @@ iris_ctx *iris_load_dir(const char *model_dir) { ctx->vae_shift = 0.0f; snprintf(path, sizeof(path), "%s/vae/config.json", model_dir); if (file_exists(path)) { - FILE *f = fopen(path, "r"); + FILE *f = fopen(path, "rb"); if (f) { char buf[4096]; size_t n = fread(buf, 1, sizeof(buf) - 1, f); diff --git a/iris_qwen3.c b/iris_qwen3.c index 886e3e1..98b95c4 100644 --- a/iris_qwen3.c +++ b/iris_qwen3.c @@ -1379,7 +1379,7 @@ static int parse_qwen3_config(const char *model_dir, qwen3_model_t *model) { char path[1024]; snprintf(path, sizeof(path), "%s/config.json", model_dir); - FILE *f = fopen(path, "r"); + FILE *f = fopen(path, "rb"); if (!f) return -1; char buf[8192]; @@ -1453,7 +1453,7 @@ static int open_safetensors_shards(const char *model_dir, /* First try: read the index JSON to discover shard filenames */ snprintf(path, sizeof(path), "%s/model.safetensors.index.json", model_dir); - FILE *f = fopen(path, "r"); + FILE *f = fopen(path, "rb"); if (f) { /* Read the whole index file */ fseek(f, 0, SEEK_END); diff --git a/iris_safetensors.c b/iris_safetensors.c index 968a103..5cc87c0 100644 --- a/iris_safetensors.c +++ b/iris_safetensors.c @@ -6,10 +6,26 @@ #include #include #include +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#else #include #include #include #include +#endif + +/* Release a read-only file mapping created in safetensors_open(). */ +static void iris_unmap(void *data, size_t size) { +#ifdef _WIN32 + (void)size; /* Windows tracks the view size internally */ + UnmapViewOfFile(data); +#else + munmap(data, size); +#endif +} /* Minimal JSON parser for safetensors header */ @@ -205,6 +221,49 @@ static int parse_header(safetensors_file_t *sf) { * OS page in tensor data on demand, avoiding upfront reads of multi-GB model * files -- only the weights actually used get loaded into RAM. */ safetensors_file_t *safetensors_open(const char *path) { +#ifdef _WIN32 + HANDLE fh = CreateFileA(path, GENERIC_READ, FILE_SHARE_READ, NULL, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + if (fh == INVALID_HANDLE_VALUE) { + fprintf(stderr, "safetensors_open: cannot open %s (error %lu)\n", + path, (unsigned long)GetLastError()); + return NULL; + } + + LARGE_INTEGER fsize; + if (!GetFileSizeEx(fh, &fsize)) { + fprintf(stderr, "safetensors_open: cannot stat %s (error %lu)\n", + path, (unsigned long)GetLastError()); + CloseHandle(fh); + return NULL; + } + + size_t file_size = (size_t)fsize.QuadPart; + if (file_size < 8) { + fprintf(stderr, "safetensors_open: file too small\n"); + CloseHandle(fh); + return NULL; + } + + HANDLE mapping = CreateFileMappingA(fh, NULL, PAGE_READONLY, 0, 0, NULL); + if (!mapping) { + fprintf(stderr, "safetensors_open: CreateFileMapping failed (error %lu)\n", + (unsigned long)GetLastError()); + CloseHandle(fh); + return NULL; + } + + void *data = MapViewOfFile(mapping, FILE_MAP_READ, 0, 0, 0); + /* The view holds its own references, so both handles can go now. */ + CloseHandle(mapping); + CloseHandle(fh); + + if (!data) { + fprintf(stderr, "safetensors_open: MapViewOfFile failed (error %lu)\n", + (unsigned long)GetLastError()); + return NULL; + } +#else int fd = open(path, O_RDONLY); if (fd < 0) { perror("safetensors_open: open failed"); @@ -232,6 +291,7 @@ safetensors_file_t *safetensors_open(const char *path) { perror("safetensors_open: mmap failed"); return NULL; } +#endif /* Read header size (8-byte little-endian) */ uint64_t header_size = 0; @@ -239,13 +299,13 @@ safetensors_file_t *safetensors_open(const char *path) { if (header_size > file_size - 8) { fprintf(stderr, "safetensors_open: invalid header size\n"); - munmap(data, file_size); + iris_unmap(data, file_size); return NULL; } safetensors_file_t *sf = calloc(1, sizeof(safetensors_file_t)); if (!sf) { - munmap(data, file_size); + iris_unmap(data, file_size); return NULL; } @@ -294,7 +354,7 @@ safetensors_file_t *safetensors_open(const char *path) { void safetensors_close(safetensors_file_t *sf) { if (!sf) return; - if (sf->data) munmap(sf->data, sf->file_size); + if (sf->data) iris_unmap(sf->data, sf->file_size); free(sf->path); free(sf->header_json); free(sf); diff --git a/iris_transformer_flux.c b/iris_transformer_flux.c index 267fa5a..bc28bca 100644 --- a/iris_transformer_flux.c +++ b/iris_transformer_flux.c @@ -84,6 +84,11 @@ static double tf_get_time_ms(void) { #endif #include #include +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include /* GetSystemInfo: MinGW has unistd.h but not sysconf */ +#endif #endif /* Use Metal for GPU acceleration when available */ @@ -337,7 +342,7 @@ static int parse_transformer_config(const char *model_dir, iris_transformer_flux char path[1024]; snprintf(path, sizeof(path), "%s/transformer/config.json", model_dir); - FILE *f = fopen(path, "r"); + FILE *f = fopen(path, "rb"); if (!f) return -1; char buf[4096]; @@ -407,7 +412,7 @@ static int open_transformer_shards(const char *model_dir, /* Try to read index JSON for sharded models */ snprintf(path, sizeof(path), "%s/transformer/diffusion_pytorch_model.safetensors.index.json", model_dir); - FILE *fp = fopen(path, "r"); + FILE *fp = fopen(path, "rb"); if (fp) { fseek(fp, 0, SEEK_END); long len = ftell(fp); @@ -1562,7 +1567,13 @@ static void *joint_attn_thread_worker(void *arg) { static int get_attn_num_threads(int heads) { static int cached = 0; if (cached) return cached; +#ifdef _WIN32 + SYSTEM_INFO si; + GetSystemInfo(&si); + int ncpu = (int)si.dwNumberOfProcessors; +#else int ncpu = (int)sysconf(_SC_NPROCESSORS_ONLN); +#endif if (ncpu < 2) { cached = 1; return 1; } if (ncpu > heads) ncpu = heads; /* Round down to divide heads evenly */ diff --git a/iris_transformer_zimage.c b/iris_transformer_zimage.c index ffdf7c1..1bf7df9 100644 --- a/iris_transformer_zimage.c +++ b/iris_transformer_zimage.c @@ -2193,7 +2193,7 @@ zi_transformer_t *zi_transformer_load_safetensors(const char *model_dir, /* Try index file first for sharded models */ snprintf(path, sizeof(path), "%s/transformer/diffusion_pytorch_model.safetensors.index.json", model_dir); - FILE *idx_f = fopen(path, "r"); + FILE *idx_f = fopen(path, "rb"); safetensors_file_t *files[ZI_MAX_SHARDS] = {0}; int n_files = 0; diff --git a/main.c b/main.c index 82c23be..a03f50b 100644 --- a/main.c +++ b/main.c @@ -22,7 +22,9 @@ #include "iris.h" #include "iris_kernels.h" +#ifndef IRIS_NO_REPL #include "iris_cli.h" +#endif #include "terminals.h" #include #include @@ -529,9 +531,19 @@ int main(int argc, char *argv[]) { /* Interactive mode: start REPL */ if (interactive_mode) { +#ifdef IRIS_NO_REPL + fprintf(stderr, + "Interactive mode is not available in this build.\n" + "Give a prompt and an output path instead, for example:\n" + " %s -d %s -p \"a cat\" -o out.png\n", + argv[0], model_dir); + iris_free(ctx); + return 1; +#else int rc = iris_cli_run(ctx, model_dir); iris_free(ctx); return rc; +#endif } /* Set up progress callbacks (for normal and verbose modes) */ diff --git a/terminals.c b/terminals.c index 4cd2515..0ab34db 100644 --- a/terminals.c +++ b/terminals.c @@ -302,6 +302,12 @@ int iterm2_display_png(const char *path) { int iterm2_display_image(const iris_image *img) { if (!img || !img->data) return -1; +#ifdef _WIN32 + /* iTerm2 is macOS-only, so this protocol is never detected here, and + * MinGW has no mkstemps(). Fail instead of carrying a dead temp-file + * implementation that nothing on Windows can reach. */ + return -1; +#else /* Create temp file for PNG */ char tmppath[] = "/tmp/iris_iterm_XXXXXX.png"; int fd = mkstemps(tmppath, 4); @@ -323,6 +329,7 @@ int iterm2_display_image(const iris_image *img) { /* Clean up */ unlink(tmppath); return result; +#endif } /* ======================================================================