From e1326b145e7fb001c92ee6b715e3bbd9e219ccc7 Mon Sep 17 00:00:00 2001 From: jaakko Date: Thu, 2 Apr 2026 12:49:40 +0300 Subject: [PATCH] =?UTF-8?q?hetki=20ennen=20webgpu=20inferenssi=C3=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- network-poc/README.md | 11 +- network-poc/USER-README.md | 106 ++++++++++++------ network-poc/hub/src/main.rs | 87 +++++++++++++-- network-poc/native-node/Cargo.toml | 2 +- network-poc/node/src/lib.rs | 1 + network-poc/node/src/qwen.rs | 4 +- network-poc/node/src/qwen_coder.rs | 24 +++- network-poc/node/src/sampling.rs | 47 ++++++++ network-poc/node/src/smollm.rs | 4 +- network-poc/static/index.html | 169 +++++++++++++++++++++++++---- 10 files changed, 375 insertions(+), 80 deletions(-) create mode 100644 network-poc/node/src/sampling.rs diff --git a/network-poc/README.md b/network-poc/README.md index eb6a23c..e488289 100644 --- a/network-poc/README.md +++ b/network-poc/README.md @@ -41,14 +41,16 @@ Hajautettu AI-laskentaverkko selaimessa ja natiivina. Käyttäjät tarjoavat GPU - `lib.rs` — Wasm-entrypoint, tehtävävalinta (`SELECTED_TASK`), WebSocket-handler, GPU/CPU-valinta - `storage.rs` — IndexedDB read/write (tokenizer, mallin painot) +- `sampling.rs` — Top-k sampling EOS-penaltilla (kiertää Candlen softmax Wasm-bugin) - `smollm.rs` — SmolLM 135M Candle-inferenssi (Llama-arkkitehtuuri) - `qwen.rs` — Qwen2.5 0.5B Candle-inferenssi (Qwen2-arkkitehtuuri) +- `qwen_coder.rs` — Qwen2.5-Coder 0.5B/3B koodigenerointi (sama arkkitehtuuri, koodikoulutettu) - `phi3.rs` — Phi-3 placeholder (liian iso selaimelle) ### Native Node (`native-node/src/`) - `main.rs` — GPU-tunnistus (wgpu + NVML + sysfs + Apple), HF Hub -lataus, WS-yhteys -- `inference.rs` — Qwen2.5-0.5B Candle-inferenssi, KV-cache reset per prompti, mmap-lataus +- `inference.rs` — Qwen2.5-0.5B Candle-inferenssi, CUDA/CPU, KV-cache reset per prompti, mmap-lataus ## Kehitysympäristö @@ -87,6 +89,7 @@ Solmu → hub: | `llm_done` | LLM-tulos: `{response, tokens_generated, tokens_per_sec}` | | `llm_chunk` | Streaming-token | | `download_progress` | Mallin latauksen edistyminen | +| `user_text` | Käyttäjän oma teksti: `{text, task_type}` | ## API-endpointit @@ -105,6 +108,7 @@ Solmu → hub: - **IP-rajoitus** — max 4 WS-yhteyttä per IP, X-Forwarded-For -tuki - **Viestivalidointi** — pakollinen `type`, sallitut tyypit, kenttäkohtaiset rajat - **Viestikoko** — max 16 KB per WebSocket-viesti +- **Admin Basic Auth** — `/admin` ja `/api/*` salasanan takana (`ADMIN_PASSWORD` env, oletus: `kipina`) - **Caddy** — automaattinen TLS (Let's Encrypt) ## Tuotanto-deploy @@ -119,10 +123,11 @@ docker compose -f docker-compose.prod.yml down && docker compose -f docker-compo ## Tiedossa olevat rajoitukset -- LLM-inferenssi on **greedy** (argmax) — ei temperature/top-p samplingia Wasmissa (Candlen `SoftmaxLastDim` bugi) +- LLM-inferenssi käyttää **top-k samplingia** (k=10, EOS-penaltti) — ei täyttä temperature/top-p -tukea Wasmissa - Qwen selaimessa: ~0.4 tok/s CPU — käyttökelpoinen demona mutta ei tuotantoon +- Native node + CUDA: ~50-100 tok/s (RTX 4090) - Hub broadcastaa kaikki viestit kaikille — ei kohdennettu reititystä -- CUDA-tuki vaatii `nvidia-cuda-toolkit` asennuksen + Cargo.toml featuren +- 3B Coder-malli vaatii ~12 GB RAM selaimessa (Wasm) ## Lisenssi diff --git a/network-poc/USER-README.md b/network-poc/USER-README.md index 39323ce..a139de7 100644 --- a/network-poc/USER-README.md +++ b/network-poc/USER-README.md @@ -15,20 +15,29 @@ Kipinä Agentic Network on hajautettu tekoälylaskentaverkko, jossa selaimet ja jos WebGPU ei tuettu ``` -**Hub** jakaa tokenisointitehtäviä satunnaisesti 10 sekunnin välein. Solmut tokenisoivat syötteen Qwen2.5-Coder-tokenizerin avulla ja palauttavat tuloksen. Hub näyttää tulokset terminaalissa ja välittää ne dashboardiin. +**Hub** jakaa tehtäviä (tokenisointiparit, LLM-promptit, kooditehtävät) 10 sekunnin välein. Solmut käsittelevät vain valitsemansa tehtävätyypin mukaisia viestejä. -## Kaksi tapaa osallistua verkkoon +## Kolme tapaa osallistua verkkoon -### 1. Selainsolmu (Wasm + WebGPU) -- Avaa `http://localhost:3000` | `https://kipina.studio` selaimessa ja klikkaa "Liity laskentaverkkoon" -- Selain tunnistaa automaattisesti WebGPU-tuen — jos ei löydy, käytetään CPU-fallbackia -- Tokenizer ladataan HuggingFacesta ensimmäisellä kerralla ja tallennetaan IndexedDB:hen -- GPU-kuormitusta voi säätää sliderilla (0–75 %) +### 1. Selainsolmu — Laskentaverkko +- Avaa `http://localhost:3000` | `https://kipina.studio` ja valitse tehtävä: + - **Tokenisointivertailu** — EN/FI-kieliparien BPE-tokenisointitehokkuus (~7 MB lataus) + - **SmolLM 135M** — kevyt LLM-inferenssi (~269 MB, ~1.2 tok/s) + - **Qwen2.5 0.5B** — tehokkaampi LLM (~990 MB, ~0.4 tok/s) + - **Phi-3 Mini 3.8B** — vain native-nodella +- WebGPU tunnistetaan automaattisesti, CPU-fallback jos ei tuettu +- Mallit ja tokenizerit cachetetaan IndexedDB:hen -### 2. Natiivi-node (Rust + NVML) +### 2. Selainsolmu — Koodilaboratorio +- Erillinen välilehti: **Qwen2.5-Coder** koodigenerointi +- Valittavissa **0.5B** (nopea) tai **3B** (laadukas, 6.2 GB lataus) +- Oma promptti: kirjoita Python-ohjelmointitehtävä ja paina "Generate" +- Syntaksikorostettu koodivastaus + +### 3. Natiivi-node (Rust + CUDA/CPU) +- Qwen2.5-0.5B-Instruct inferenssi CUDA:lla (~50-100 tok/s RTX 4090) tai CPU:lla (~11 tok/s) - Kerää nvidia-smi-tason laitteistotiedot: GPU-nimi, VRAM, lämpötila, kuormitus -- Raportoi järjestelmätiedot: CPU-malli, ytimet, RAM, OS -- Yhdistää hubiin ja vastaanottaa tehtäviä +- Lataa mallin automaattisesti HuggingFace Hubista (~990 MB, cachetetaan) ## Käynnistys @@ -65,23 +74,26 @@ CARGO_TARGET_DIR=target-native HUB_URL=ws://localhost:3000/ws ALLOCATED_GB=4 car CARGO_TARGET_DIR=target-native HUB_URL=wss://kipina.studio/ws ALLOCATED_GB=4 cargo run --release -p native-node ``` -### CUDA-tuki (valinnainen) +### CUDA-tuki -Jos koneessa on NVIDIA GPU ja CUDA toolkit: +CUDA on oletuksena päällä native-nodessa. Vaatii `nvidia-cuda-toolkit`:n: ```bash -# Asenna CUDA toolkit (Ubuntu/Pop!_OS) +# Asenna (Ubuntu/Pop!_OS) sudo apt install nvidia-cuda-toolkit -# Muokkaa native-node/Cargo.toml: -# candle-core = { version = "0.8", features = ["cuda"] } +# Tarkista +nvcc --version -# Aja — malli käyttää automaattisesti GPU:ta +# Aja — tunnistaa CUDA:n automaattisesti, fallback CPU:lle CARGO_TARGET_DIR=target-native HUB_URL=ws://localhost:3000/ws cargo run --release -p native-node -CARGO_TARGET_DIR=target-native HUB_URL=ws://kipina.studio/ws cargo run --release -p native-node +# Tuotantoon +CARGO_TARGET_DIR=target-native HUB_URL=wss://kipina.studio/ws cargo run --release -p native-node ``` +Jos CUDA:a ei ole, poista feature: `candle-core = { version = "0.8" }` (ilman `features = ["cuda"]`). + ## WebGPU-asetukset selaimessa WebGPU ei ole oletuksena päällä kaikissa selaimissa. Jos "Liity laskentaverkkoon" -nappi käynnistää CPU-fallbackin vaikka koneessa on näytönohjain: @@ -114,19 +126,31 @@ flatpak run com.brave.Browser --enable-unsafe-webgpu --enable-features=Vulkan -- ``` network-poc/ ├── hub/ # Keskuspalvelin (Rust + Axum) -│ └── src/main.rs # WebSocket-reititin, tehtävien jakelu, statistiikat +│ └── src/ +│ ├── main.rs # WebSocket-reititin, tehtävien jakelu, admin HTML, Basic Auth +│ └── db.rs # SQLite: node_sessions, pair_results ├── node/ # Selainsolmu (Rust → Wasm) │ └── src/ -│ ├── lib.rs # WebGPU/NdArray-laskenta, tokenisaatio, WS-yhteys -│ └── storage.rs # IndexedDB-välimuisti (tokenizer) -├── native-node/ # Natiivi-solmu (Rust) -│ └── src/main.rs # NVML GPU-tunnistus, sysinfo, WS-yhteys +│ ├── lib.rs # Wasm-entrypoint, tehtävävalinta, WS-handler +│ ├── storage.rs # IndexedDB-välimuisti +│ ├── sampling.rs # Top-k sampling (EOS-penaltti) +│ ├── smollm.rs # SmolLM 135M inferenssi +│ ├── qwen.rs # Qwen2.5 0.5B inferenssi +│ ├── qwen_coder.rs # Qwen2.5-Coder 0.5B/3B koodigenerointi +│ └── phi3.rs # Phi-3 placeholder +├── native-node/ # Natiivi-solmu (Rust + CUDA) +│ └── src/ +│ ├── main.rs # GPU-tunnistus, WS-yhteys, tehtäväkäsittely +│ └── inference.rs # Qwen2.5-0.5B Candle-inferenssi (CUDA/CPU) ├── static/ -│ ├── index.html # Dashboard-käyttöliittymä +│ ├── index.html # Dashboard + Koodilaboratorio │ └── pkg/ # Wasm-build (generoidaan) -├── docker-compose.yml -├── Dockerfile.dev # Hub + Wasm-build -└── Dockerfile.native-node +├── deploy.sh # Lokaali build → palvelimelle +├── docker-compose.yml # Kehitys +├── docker-compose.prod.yml # Tuotanto (Caddy + Hub) +├── docker-compose.client.yml # Client-nodejen Docker +├── Dockerfile.prod # Tuotanto-image (cache mount) +└── Caddyfile.prod # TLS + reverse proxy ``` ## Ympäristömuuttujat @@ -135,15 +159,27 @@ network-poc/ |---|---|---| | `HUB_URL` | `ws://hub:3000/ws` | Hub-palvelimen WebSocket-osoite (native-node) | | `ALLOCATED_GB` | `4` | Solmun varaama muisti verkosta (GB) | +| `ADMIN_PASSWORD` | `kipina` | Admin-sivun ja API:n salasana (Basic Auth) | +| `DATABASE_PATH` | `nodes.db` | SQLite-tietokannan polku | +| `STATIC_DIR` | `../static` | Staattisten tiedostojen kansio | -## Kehitysvaihe +## Admin-sivu -Tämä on proof-of-concept. Toimivat osat: -- Hub-palvelin, WebSocket-viestintä, dashboard -- WebGPU-tensorilaskenta selaimessa (Burn + Wgpu) -- CPU-fallback selaimissa ilman WebGPU-tukea (Burn + NdArray) -- Natiivi-node nvidia-smi-tason laitteistotiedoilla -- Qwen2.5-Coder-tokenizer + IndexedDB-välimuisti -- GPU-kuormituksen säätö (duty cycle throttling) +`https://kipina.studio/admin` (Basic Auth, salasana: `ADMIN_PASSWORD`) -Seuraavaksi: oikea LLM-inferenssi hajautetusti (mallin painojen lataus, transformer-arkkitehtuuri Wasm/WebGPU:lla). +Sisältää: +- Node-sessiot: IP, laitetiedot, GPU, WebGPU-tuki, tehtävätyyppi, uptime +- Tokenisointitulokset: EN/FI-vertailut, ylikustannus-% +- Yhteenvetotilastot: sessiot, WebGPU vs CPU, keskiarvot + +## Projektin tila + +Toimivat ominaisuudet: +- Tokenisointivertailu (EN/FI, BPE, top-k sampling) +- SmolLM 135M inferenssi selaimessa (Candle + Wasm) +- Qwen2.5 0.5B inferenssi selaimessa (Candle + Wasm) +- Qwen2.5-Coder 0.5B/3B koodigenerointi (Koodilaboratorio-välilehti) +- Native node + CUDA (RTX 4090: ~50-100 tok/s) +- Admin-dashboard + SQLite + Basic Auth +- Deploy-skripti (lokaali build → palvelin) +- WebGPU + CPU fallback, GPU-tunnistus (NVIDIA/AMD/Apple) diff --git a/network-poc/hub/src/main.rs b/network-poc/hub/src/main.rs index f4dd885..f20fa21 100644 --- a/network-poc/hub/src/main.rs +++ b/network-poc/hub/src/main.rs @@ -157,13 +157,30 @@ async function load() { {v: stats.avg_overhead_pct + '%', l: 'FI ylikust. (ka.)'}, ].map(s => `
${s.v}
${s.l}
`).join(''); - // Sessions + // Sessions — lajittelu: 1) aktiiviset nodet (online + ei viewer), 2) katsojat (online + viewer), 3) offline + const taskNames = {'tokenize':'Tokenisaatio','smollm-135m':'SmolLM 135M','qwen-05b':'Qwen2.5 0.5B','phi3-mini':'Phi-3 Mini','qwen-coder-05b':'Coder 0.5B','qwen-coder-3b':'Coder 3B','viewer':'Katsoja'}; + sessions.sort((a, b) => { + const aOnline = !a.disconnected_at; + const bOnline = !b.disconnected_at; + const aViewer = a.selected_task === 'viewer'; + const bViewer = b.selected_task === 'viewer'; + // Online ennen offlinea + if (aOnline !== bOnline) return aOnline ? -1 : 1; + // Online: aktiiviset nodet ennen katsojia + if (aOnline && bOnline && aViewer !== bViewer) return aViewer ? 1 : -1; + // Saman ryhmän sisällä: uusin ensin + return new Date(b.connected_at) - new Date(a.connected_at); + }); + document.getElementById('sessions-body').innerHTML = sessions.map(s => { const online = !s.disconnected_at; - const status = online ? 'ONLINE' : 'offline'; + const isViewer = s.selected_task === 'viewer'; + const status = online + ? (isViewer ? 'CONNECTED' : 'ACTIVE') + : 'offline'; const typeBadge = s.node_type === 'native' ? badge('native','blue') : badge('browser','yellow'); - const taskNames = {'tokenize':'Tokenisaatio','smollm-135m':'SmolLM 135M','qwen-05b':'Qwen2.5 0.5B','phi3-mini':'Phi-3 Mini'}; - const taskBadge = badge(taskNames[s.selected_task] || s.selected_task || 'tokenize', s.selected_task === 'tokenize' ? 'green' : 'blue'); + const taskColor = isViewer ? 'yellow' : s.selected_task === 'tokenize' ? 'green' : 'blue'; + const taskBadge = badge(taskNames[s.selected_task] || s.selected_task || '?', taskColor); const gpuBadge = s.has_webgpu ? badge('WebGPU','green') : badge('CPU','red'); const gpu = s.gpu_name ? `${s.gpu_name}` : '-'; const vram = s.vram_total_mb ? `${s.vram_total_mb} MB` : '-'; @@ -346,27 +363,73 @@ async fn main() { } async fn api_sessions( + headers: axum::http::HeaderMap, axum::extract::State(state): axum::extract::State>, -) -> impl IntoResponse { - axum::Json(state.db.get_sessions(200)) +) -> axum::response::Response { + if !check_admin_auth(&headers) { return admin_unauthorized(); } + axum::Json(state.db.get_sessions(200)).into_response() } async fn api_pairs( + headers: axum::http::HeaderMap, axum::extract::State(state): axum::extract::State>, -) -> impl IntoResponse { - axum::Json(state.db.get_pair_results(500)) +) -> axum::response::Response { + if !check_admin_auth(&headers) { return admin_unauthorized(); } + axum::Json(state.db.get_pair_results(500)).into_response() } async fn api_stats( + headers: axum::http::HeaderMap, axum::extract::State(state): axum::extract::State>, -) -> impl IntoResponse { +) -> axum::response::Response { + if !check_admin_auth(&headers) { return admin_unauthorized(); } let mut stats = state.db.get_stats(); stats.as_object_mut().unwrap().insert("version".to_string(), serde_json::json!(env!("CARGO_PKG_VERSION"))); - axum::Json(stats) + axum::Json(stats).into_response() } -async fn admin_page() -> impl IntoResponse { - axum::response::Html(ADMIN_HTML) +fn check_admin_auth(headers: &axum::http::HeaderMap) -> bool { + let password = std::env::var("ADMIN_PASSWORD").unwrap_or_else(|_| "kipina".to_string()); + if let Some(auth) = headers.get("authorization").and_then(|v| v.to_str().ok()) { + if auth.starts_with("Basic ") { + if let Ok(decoded) = String::from_utf8( + base64_decode(auth.trim_start_matches("Basic ").trim()) + ) { + // Tarkistetaan "user:password" — käyttäjänimi ei väliä + if let Some(pass) = decoded.split(':').nth(1) { + return pass == password; + } + } + } + } + false +} + +fn base64_decode(input: &str) -> Vec { + // Yksinkertainen base64-dekooderi + const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = Vec::new(); + let bytes: Vec = input.bytes().filter(|&b| b != b'=').collect(); + for chunk in bytes.chunks(4) { + let vals: Vec = chunk.iter().filter_map(|&b| TABLE.iter().position(|&t| t == b).map(|p| p as u8)).collect(); + if vals.len() >= 2 { out.push((vals[0] << 2) | (vals[1] >> 4)); } + if vals.len() >= 3 { out.push((vals[1] << 4) | (vals[2] >> 2)); } + if vals.len() >= 4 { out.push((vals[2] << 6) | vals[3]); } + } + out +} + +fn admin_unauthorized() -> axum::response::Response { + axum::response::Response::builder() + .status(401) + .header("WWW-Authenticate", "Basic realm=\"Kipinä Admin\"") + .body(axum::body::Body::from("Unauthorized")) + .unwrap() +} + +async fn admin_page(headers: axum::http::HeaderMap) -> axum::response::Response { + if !check_admin_auth(&headers) { return admin_unauthorized(); } + axum::response::Html(ADMIN_HTML).into_response() } async fn ws_handler( diff --git a/network-poc/native-node/Cargo.toml b/network-poc/native-node/Cargo.toml index 36344e6..90896b0 100644 --- a/network-poc/native-node/Cargo.toml +++ b/network-poc/native-node/Cargo.toml @@ -12,7 +12,7 @@ serde_json = "1.0" sysinfo = "0.30" nvml-wrapper = "0.10" wgpu = "24" -candle-core = { version = "0.8" } +candle-core = { version = "0.8", features = ["cuda"] } candle-nn = "0.8" candle-transformers = "0.8" hf-hub = "0.4" diff --git a/network-poc/node/src/lib.rs b/network-poc/node/src/lib.rs index ef07ece..03df2db 100644 --- a/network-poc/node/src/lib.rs +++ b/network-poc/node/src/lib.rs @@ -7,6 +7,7 @@ use burn::tensor::Tensor; use burn::backend::{Wgpu, NdArray}; pub mod storage; +pub mod sampling; pub mod smollm; pub mod qwen; pub mod qwen_coder; diff --git a/network-poc/node/src/qwen.rs b/network-poc/node/src/qwen.rs index 1494e3c..42be9ec 100644 --- a/network-poc/node/src/qwen.rs +++ b/network-poc/node/src/qwen.rs @@ -154,7 +154,7 @@ pub async fn run_qwen_inference(prompt: String, ws: Rc>) { } else { logits // jo [vocab_size] }; - let mut next_token = logits.argmax(0).unwrap().to_vec0::().unwrap(); + let mut next_token = crate::sampling::sample_top_k(&logits, 10, 5.0); console_log!("[Qwen] Ensimmäinen token: {}", next_token); let eos_token = 151645u32; // <|endoftext|> for Qwen2.5 @@ -188,7 +188,7 @@ pub async fn run_qwen_inference(prompt: String, ws: Rc>) { } else { logits }; - next_token = logits.argmax(0).unwrap().to_vec0::().unwrap(); + next_token = crate::sampling::sample_top_k(&logits, 10, 5.0); pos += 1; if next_token == eos_token { break; } diff --git a/network-poc/node/src/qwen_coder.rs b/network-poc/node/src/qwen_coder.rs index d01ff01..d22d40c 100644 --- a/network-poc/node/src/qwen_coder.rs +++ b/network-poc/node/src/qwen_coder.rs @@ -173,8 +173,22 @@ pub async fn run_coder_inference(prompt: String, ws: Rc>, use let load_time = perf.now() - start_load; console_log!("[Coder] Malli ladattu ({:.0}ms). Generoidaan...", load_time); - // Muotoillaan chat-template - let formatted = format!("<|im_start|>system\nYou are a Python coding assistant. Write only code, no explanations.<|im_end|>\n<|im_start|>user\n{}<|im_end|>\n<|im_start|>assistant\n", prompt); + // Parsitaan JSON-prompti tai käytetään teksti sellaisenaan + let (actual_prompt, system_msg, max_new_tokens) = if prompt.starts_with('{') { + if let Ok(json) = serde_json::from_str::(&prompt) { + let p = json.get("prompt").and_then(|v| v.as_str()).unwrap_or(&prompt).to_string(); + let s = json.get("system").and_then(|v| v.as_str()) + .unwrap_or("You are a Python coding assistant. Write only code, no explanations.").to_string(); + let m = json.get("max_tokens").and_then(|v| v.as_u64()).unwrap_or(128) as usize; + (p, s, m) + } else { + (prompt.clone(), "You are a Python coding assistant. Write only code, no explanations.".to_string(), 128) + } + } else { + (prompt.clone(), "You are a Python coding assistant. Write only code, no explanations.".to_string(), 128) + }; + + let formatted = format!("<|im_start|>system\n{}<|im_end|>\n<|im_start|>user\n{}<|im_end|>\n<|im_start|>assistant\n", system_msg, actual_prompt); let encoding = match tokenizer.encode(formatted.as_str(), true) { Ok(e) => e, @@ -185,7 +199,7 @@ pub async fn run_coder_inference(prompt: String, ws: Rc>, use console_log!("[Coder] Syöte: {} tokenia", input_len); let start_gen = perf.now(); - let max_new_tokens = 128; // Koodille enemmän tokeneita + // max_new_tokens tulee JSON-promptista tai oletuksena 128 let mut generated_text = String::new(); let mut tokens_generated: usize = 0; let eos_token = 151645u32; @@ -206,7 +220,7 @@ pub async fn run_coder_inference(prompt: String, ws: Rc>, use } else { logits }; - let mut next_token = logits.argmax(0).unwrap().to_vec0::().unwrap(); + let mut next_token = crate::sampling::sample_top_k(&logits, 10, 5.0); if next_token != eos_token { if let Ok(text) = tokenizer.decode(&[next_token], true) { @@ -237,7 +251,7 @@ pub async fn run_coder_inference(prompt: String, ws: Rc>, use } else { logits }; - next_token = logits.argmax(0).unwrap().to_vec0::().unwrap(); + next_token = crate::sampling::sample_top_k(&logits, 10, 5.0); pos += 1; if next_token == eos_token { break; } diff --git a/network-poc/node/src/sampling.rs b/network-poc/node/src/sampling.rs new file mode 100644 index 0000000..e0a39f5 --- /dev/null +++ b/network-poc/node/src/sampling.rs @@ -0,0 +1,47 @@ +use candle_core::Tensor; + +/// Top-k sampling ilman softmaxia — kiertää Candlen SoftmaxLastDim Wasm-bugin. +/// Valitsee top-k logiteista ja poimii satunnaisen (painotettu). +/// Jos k=1, toimii kuten argmax (greedy). +pub fn sample_top_k(logits: &Tensor, k: usize, eos_penalty: f32) -> u32 { + // Muunnetaan Vec:ksi + let logits_vec: Vec = logits.to_vec1::().unwrap_or_default(); + if logits_vec.is_empty() { return 0; } + + // Rangotaan ja otetaan top-k indeksit + let mut indexed: Vec<(usize, f32)> = logits_vec.iter().enumerate().map(|(i, &v)| (i, v)).collect(); + indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + indexed.truncate(k); + + // EOS-penaltti: vähennetään EOS-tokenin logitia + for item in indexed.iter_mut() { + if item.0 == 2 || item.0 == 151645 { // SmolLM EOS=2, Qwen EOS=151645 + item.1 -= eos_penalty; + } + } + + if k == 1 { + return indexed[0].0 as u32; + } + + // Yksinkertainen "softmax" top-k:lle CPU:lla + let max_logit = indexed.iter().map(|x| x.1).fold(f32::NEG_INFINITY, f32::max); + let exps: Vec = indexed.iter().map(|x| (x.1 - max_logit).exp()).collect(); + let sum: f32 = exps.iter().sum(); + let probs: Vec = exps.iter().map(|e| e / sum).collect(); + + // Satunnainen valinta kumulatiivisella todennäköisyydellä + // Käytetään yksinkertaista XorShift-satunnaislukugeneraattoria (ei tarvita getrandom) + let seed = (js_sys::Date::now() * 1000.0) as u64; + let rand_val = ((seed ^ (seed >> 13) ^ (seed << 7)) % 10000) as f32 / 10000.0; + + let mut cumulative = 0.0; + for (i, p) in probs.iter().enumerate() { + cumulative += p; + if rand_val < cumulative { + return indexed[i].0 as u32; + } + } + + indexed[0].0 as u32 +} diff --git a/network-poc/node/src/smollm.rs b/network-poc/node/src/smollm.rs index afd2d8e..2176467 100644 --- a/network-poc/node/src/smollm.rs +++ b/network-poc/node/src/smollm.rs @@ -196,7 +196,7 @@ pub async fn run_smollm_inference(prompt: String, ws: Rc>) { } else { logits }; - let mut next_token = logits.argmax(0).unwrap().to_vec0::().unwrap(); + let mut next_token = crate::sampling::sample_top_k(&logits, 10, 5.0); console_log!("[SmolLM] Ensimmäinen generoitu token: {}", next_token); pos = input_len; @@ -229,7 +229,7 @@ pub async fn run_smollm_inference(prompt: String, ws: Rc>) { } else { logits }; - next_token = logits.argmax(0).unwrap().to_vec0::().unwrap(); + next_token = crate::sampling::sample_top_k(&logits, 10, 5.0); pos += 1; if next_token == 2 { break; } diff --git a/network-poc/static/index.html b/network-poc/static/index.html index 2ca495e..4cac502 100644 --- a/network-poc/static/index.html +++ b/network-poc/static/index.html @@ -567,9 +567,37 @@ -
- - +
+
+
+ + + +
+
+ + +
+
@@ -634,12 +662,19 @@ document.querySelectorAll('.main-panel').forEach(p => p.classList.remove('active')); document.querySelectorAll('.main-tab').forEach(t => t.classList.remove('active')); document.getElementById('panel-' + tab).classList.add('active'); - event.target.classList.add('active'); + document.querySelector(`.main-tab[onclick*="${tab}"]`).classList.add('active'); + window.location.hash = tab; }; + // URL-hash navigointi: #codelab tai #network + if (window.location.hash === '#codelab') { + switchMainTab('codelab'); + } + // Koodilaboratorion tila const codeMetrics = { tasks: 0, tokens: 0, lastSpeed: 0 }; let coderJoined = false; + let wasmInitialized = false; let coderSize = '05b'; // '05b' tai '3b' // Mallivalinnan radio-napit @@ -780,6 +815,30 @@ // Kytkemme sivuston UI-puolen (JS) omaan passiiviseen WebSocket-kuuntelijaan. const uiSocket = new WebSocket(`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`); + uiSocket.onopen = () => { + const el = document.getElementById('node-status'); + el.textContent = 'Connected'; + el.style.color = '#d29922'; + + // Lähetetään kevyt auth heti — admin näkee kävijän välittömästi + const hasGPU = !!navigator.gpu; + uiSocket.send(JSON.stringify({ + type: 'auth', + status: 'viewer', + node_type: 'browser', + platform: navigator.platform || '', + cpu_cores: navigator.hardwareConcurrency || 0, + device_memory_gb: navigator.deviceMemory || 0, + allocated_gb: 0, + selected_task: 'viewer', + has_webgpu: hasGPU, + })); + }; + uiSocket.onclose = () => { + const el = document.getElementById('node-status'); + el.textContent = 'Disconnected'; + el.style.color = '#f85149'; + }; uiSocket.onmessage = (event) => { try { const data = JSON.parse(event.data); @@ -980,18 +1039,20 @@ } const gpuStr = hasWebGPU ? (deviceInfo.gpu?.description || deviceInfo.gpu?.vendor || "WebGPU") : "ei GPU:ta"; - const backendStr = hasWebGPU ? "WebGPU" : "CPU (NdArray)"; + // Laskenta käyttää aina CPU:ta (Candle), WebGPU on vain tensorilaskennassa (Burn) + const computeBackend = (selectedTask === 'tokenize') + ? (hasWebGPU ? "WebGPU + CPU" : "CPU") + : "CPU (Candle Wasm)"; const vramStr = deviceInfo.gpu?.estimated_vram_gb ? `~${deviceInfo.gpu.estimated_vram_gb} GB` : "?"; - // navigator.deviceMemory on rajoitettu max 8 GB:iin — merkitään arvio const ramNote = deviceInfo.device_memory_gb >= 8 ? "8+ GB (selaimen raja)" : `~${deviceInfo.device_memory_gb} GB`; // Näytetään laitetiedot paneelissa const diPanel = document.getElementById('device-info'); diPanel.style.display = 'block'; diPanel.innerHTML = [ - `Backend: ${backendStr}`, - `GPU: ${gpuStr}`, + `Laskenta: ${computeBackend}`, + hasWebGPU ? `GPU: ${gpuStr}` : `GPU: ei WebGPU:ta`, hasWebGPU ? `VRAM: ${vramStr}` : null, `CPU: ${deviceInfo.cpu_cores} ydintä`, `RAM: ${ramNote}`, @@ -1004,7 +1065,7 @@ if (hasWebGPU) { banner.className = 'compat-banner gpu'; - banner.innerHTML = `GPU-kiihdytys aktiivinen — ${gpuStr}`; + banner.innerHTML = `WebGPU tunnistettu — ${gpuStr}. Tokenisaatio käyttää GPU:ta, LLM-inferenssi CPU:ta (Candle Wasm).`; } else { // Tunnistetaan selain ohjeen personointia varten const ua = navigator.userAgent; @@ -1063,8 +1124,11 @@ } try { - console.log("Ladataan Burn Wasm -binääriä..."); - await init(); + if (!wasmInitialized) { + console.log("Ladataan Burn Wasm -binääriä..."); + await init(); + wasmInitialized = true; + } window.wasm_active = true; metrics.startTime = Date.now(); @@ -1095,6 +1159,23 @@ let coderWs = null; // Erillinen WS coder-nodelle let pendingCodePrompt = null; + // Yksinkertainen Python-syntaksikorostus + function highlightPython(code) { + return code + // Kommentit + .replace(/(#.*)/g, '$1') + // Merkkijonot (f-stringit, tavalliset) + .replace(/(f?"[^"]*"|f?'[^']*')/g, '$1') + // Avainsanat + .replace(/\b(def|return|if|elif|else|for|while|in|not|and|or|is|import|from|class|try|except|with|as|lambda|yield|True|False|None|raise|pass|break|continue)\b/g, '$1') + // Sisäänrakennetut funktiot + .replace(/\b(print|len|range|int|str|float|list|dict|set|tuple|type|isinstance|enumerate|zip|map|filter|sorted|reversed|sum|min|max|abs|round|input|open)\b/g, '$1') + // Numerot + .replace(/\b(\d+\.?\d*)\b/g, '$1') + // Dekoraattorit + .replace(/(@\w+)/g, '$1'); + } + function addCodeResult(data) { const model = data.model || 'Coder'; const tokGen = data.tokens_generated || 0; @@ -1122,7 +1203,7 @@ card.className = 'code-task-card'; card.innerHTML = `
${data.prompt || ''}
-
${response}
+
${highlightPython(response)}
${model} · ${tokGen} tokenia · ${typeof durMs === 'number' ? durMs.toFixed(0) : durMs}ms · ${tokS} tok/s
`; @@ -1194,7 +1275,10 @@ setStep('step-wasm', 'active'); try { - await init(); + if (!wasmInitialized) { + await init(); + wasmInitialized = true; + } setStep('step-wasm', 'done'); setStep('step-tokenizer', 'active'); @@ -1208,7 +1292,15 @@ selected_task: coderSize === '3b' ? 'qwen-coder-3b' : 'qwen-coder-05b' }; const taskId = coderSize === '3b' ? 5 : 4; - await start_agent_node(wsUrl, false, JSON.stringify(deviceInfo), taskId); + // Tunnistetaan WebGPU myös koodilaboratorion puolella + let coderHasWebGPU = false; + if (navigator.gpu) { + try { + const adapter = await navigator.gpu.requestAdapter(); + coderHasWebGPU = !!adapter; + } catch(e) {} + } + await start_agent_node(wsUrl, coderHasWebGPU, JSON.stringify(deviceInfo), taskId); document.getElementById('coder-status').textContent = 'Connected'; document.getElementById('coder-status').style.color = '#d29922'; coderWsReady = true; @@ -1225,6 +1317,24 @@ } } + // JSON mode toggle + const jsonToggle = document.getElementById('json-mode-toggle'); + const jsonHelp = document.getElementById('json-help'); + const textInput = document.getElementById('code-input'); + const jsonInput = document.getElementById('code-input-json'); + + jsonToggle?.addEventListener('change', () => { + if (jsonToggle.checked) { + textInput.style.display = 'none'; + jsonInput.style.display = 'block'; + jsonHelp.style.display = 'block'; + } else { + textInput.style.display = 'block'; + jsonInput.style.display = 'none'; + jsonHelp.style.display = 'none'; + } + }); + function sendCodeToHub(text) { if (uiSocket && uiSocket.readyState === 1) { uiSocket.send(JSON.stringify({ type: 'user_text', text: text, task_type: 'qwen-coder' })); @@ -1232,15 +1342,34 @@ } async function handleCodeSubmit() { - const text = codeInput.value.trim(); - if (!text) return; - codeInput.value = ''; + let promptText; + + if (jsonToggle.checked) { + // JSON mode + const raw = jsonInput.value.trim(); + if (!raw) return; + try { + const parsed = JSON.parse(raw); + if (!parsed.prompt) { alert('JSON must contain "prompt" field'); return; } + // Lähetetään koko JSON hubille — node lukee promptin ja parametrit + promptText = raw; + } catch(e) { + alert('Invalid JSON: ' + e.message); + return; + } + } else { + // Text mode + promptText = textInput.value.trim(); + if (!promptText) return; + textInput.value = ''; + } + codeSendBtn.disabled = true; codeSendBtn.textContent = 'Generating...'; codeLoading.style.display = 'block'; if (!coderJoined) { - pendingCodePrompt = text; + pendingCodePrompt = promptText; const dlSize = coderSize === '3b' ? '~6.2 GB' : '~990 MB'; codeLoading.textContent = `Loading Qwen2.5-Coder-${coderSize === '3b' ? '3B' : '0.5B'} (${dlSize} on first run)...`; await ensureCoderNode(); @@ -1248,12 +1377,12 @@ codeLoading.textContent = 'Generating code...'; document.getElementById('coder-status').textContent = 'Computing'; document.getElementById('coder-status').style.color = 'var(--success-color)'; - sendCodeToHub(text); + sendCodeToHub(promptText); } } codeSendBtn?.addEventListener('click', handleCodeSubmit); - codeInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') handleCodeSubmit(); }); + textInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') handleCodeSubmit(); });