From df171a89eb6d12d3cba8f895a4205f1b92d41fb2 Mon Sep 17 00:00:00 2001 From: Jeremy Fowers Date: Thu, 27 Feb 2025 14:47:47 -0500 Subject: [PATCH] Release v6.0.0 --- .../server_installer_hybrid_test.yml | 153 +++++++ .../server_installer_windows_latest.yml | 170 ++++++++ .github/workflows/test_lemonade.yml | 17 +- .github/workflows/test_lemonade_oga_cpu.yml | 14 +- NOTICE.md | 2 + README.md | 2 +- docs/lemonade/getting_started.md | 50 +-- docs/lemonade/server_spec.md | 212 +++++++--- examples/lemonade/README.md | 18 +- examples/lemonade/api_basic.py | 6 +- examples/lemonade/api_oga_cpu.py | 6 +- examples/lemonade/api_oga_cpu_streaming.py | 6 +- examples/lemonade/api_oga_hybrid.py | 6 +- examples/lemonade/api_oga_hybrid_streaming.py | 6 +- examples/lemonade/api_oga_igpu.py | 6 +- examples/lemonade/api_oga_igpu_streaming.py | 6 +- examples/lemonade/api_oga_npu.py | 6 +- examples/lemonade/api_oga_npu_streaming.py | 6 +- examples/lemonade/api_streaming.py | 6 +- examples/lemonade/demos/chat/chat_hybrid.py | 6 +- examples/lemonade/demos/chat/chat_start.py | 6 +- .../lemonade/demos/search/search_hybrid.py | 6 +- .../lemonade/demos/search/search_start.py | 6 +- img/favicon.ico | Bin 0 -> 126745 bytes installer/AMD_LICENSE | 99 +++++ installer/Installer.nsi | 333 +++++++++++++++ setup.py | 5 +- src/lemonade/cache.py | 25 +- src/lemonade/cli.py | 7 +- src/lemonade/tools/bench.py | 275 ++++++++++++ src/lemonade/tools/huggingface_bench.py | 213 ++++------ src/lemonade/tools/huggingface_load.py | 30 ++ src/lemonade/tools/llamacpp.py | 9 +- src/lemonade/tools/llamacpp_bench.py | 189 ++++----- src/lemonade/tools/mmlu.py | 8 +- src/lemonade/tools/ort_genai/oga.py | 6 + src/lemonade/tools/ort_genai/oga_bench.py | 202 +++------ src/lemonade/tools/prompt.py | 344 --------------- src/lemonade/tools/serve.py | 395 +++++++++++------- src/turnkeyml/common/filesystem.py | 2 + src/turnkeyml/common/status.py | 11 +- src/turnkeyml/sequence/sequence.py | 2 +- src/turnkeyml/version.py | 2 +- test/lemonade/llm_api.py | 27 +- test/lemonade/oga_cpu_api.py | 18 +- test/lemonade/quark_api.py | 6 +- test/lemonade/server.py | 203 +++++++++ 47 files changed, 2062 insertions(+), 1071 deletions(-) create mode 100644 .github/workflows/server_installer_hybrid_test.yml create mode 100644 .github/workflows/server_installer_windows_latest.yml create mode 100644 img/favicon.ico create mode 100644 installer/AMD_LICENSE create mode 100644 installer/Installer.nsi create mode 100644 src/lemonade/tools/bench.py create mode 100644 test/lemonade/server.py diff --git a/.github/workflows/server_installer_hybrid_test.yml b/.github/workflows/server_installer_hybrid_test.yml new file mode 100644 index 00000000..064589f9 --- /dev/null +++ b/.github/workflows/server_installer_hybrid_test.yml @@ -0,0 +1,153 @@ +name: Hybrid Server Installer 🌩ī¸ + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + workflow_dispatch: + +jobs: + hybrid-server-installer: + runs-on: stx + steps: + - uses: actions/checkout@v4 + + - name: Install NSIS + run: | + # Download NSIS installer + Invoke-WebRequest -UserAgent "Wget" -Uri "https://sourceforge.net/projects/nsis/files/NSIS%203/3.10/nsis-3.10-setup.exe" -OutFile "nsis.exe" + + # Install NSIS + Start-Process nsis.exe -ArgumentList '/S' -Wait + + - name: Verify NSIS installation + run: | + # Check if NSIS is installed + & 'C:\Program Files (x86)\NSIS\makensis.exe' /VERSION + + - name: Build the Lemonade Server installer + run: | + cd installer + & 'C:\Program Files (x86)\NSIS\makensis.exe' 'Installer.nsi' + + if (Test-Path "Lemonade_Server_Installer.exe") { + Write-Host "Lemonade_Server_Installer.exe has been created successfully." + } else { + Write-Host "Lemonade_Server_Installer.exe was not found." + exit 1 + } + + - name: Upload Installer + uses: actions/upload-artifact@v4 + if: always() + with: + name: LemonadeServerInstaller + path: | + installer\Lemonade_Server_Installer.exe + + - name: Attempt to install Lemonade Server using installer + shell: PowerShell + run: | + cd installer + Start-Process -FilePath ".\Lemonade_Server_Installer.exe" -ArgumentList "/S /Extras=hybrid /D=C:\Users\nimbys\AppData\Local\lemonade_server" -Wait + + - name: Ensure the Lemonade serer works properly + shell: PowerShell + run: | + Write-Host "Use a function to determine the underlying command from the lemonade server shortcut" + function Get-ShortcutTarget { + param ( + [string]$shortcutPath + ) + $shell = New-Object -ComObject WScript.Shell + $shortcut = $shell.CreateShortcut($shortcutPath) + $targetPath = $shortcut.TargetPath + $arguments = $shortcut.Arguments + return "$targetPath $arguments" + } + + $shortcutPath = "C:\Users\nimbys\AppData\Local\lemonade_server\lemonade-server.lnk" + $fullCommand = Get-ShortcutTarget -shortcutPath $shortcutPath + $quotedCommand = "`"$fullCommand`"" + + $outputFile = "output.log" + $errorFile = "error.log" + $serverProcess = Start-Process -FilePath "cmd.exe" -ArgumentList "/C $quotedCommand" -RedirectStandardOutput $outputFile -RedirectStandardError $errorFile -PassThru -NoNewWindow + + Write-Host "Wait for 30 seconds to let the server come up" + Start-Sleep -Seconds 30 + + Write-Host "Check if server process successfully launched" + $serverRunning = Get-Process -Id $serverProcess.Id -ErrorAction SilentlyContinue + if (-not $serverRunning) { + Write-Host "Error: Server process isn't running, even though we just tried to start it!" + Write-Host "Standard Output:" + Get-Content $outputFile + + Write-Host "Standard Error:" + Get-Content $errorFile + exit 1 + } else { + Write-Host "Server process is alive." + } + + Write-Host "Wait for the server port to come up" + while ($true) { + + $llmPortCheck = Test-NetConnection -ComputerName 127.0.0.1 -Port 8000 + if (-not $llmPortCheck.TcpTestSucceeded) { + Write-Host "LLM server is not yet running on port 8000!" + Write-Host "Standard Output:" + Get-Content $outputFile + + Write-Host "Standard Error:" + Get-Content $errorFile + } else { + Write-Host "LLM server is running on port 8000." + break + } + + Start-Sleep -Seconds 30 + } + + Write-Host "Checking the /health endpoint" + $response = Invoke-WebRequest -Uri http://localhost:8000/api/v0/health -UseBasicParsing + + if ($response.StatusCode -eq 200) { + Write-Output "Good: /health status code is 200" + } else { + Write-Output "Error: /health status code is not 200" + Write-Host "Standard Output:" + Get-Content $outputFile + + Write-Host "Standard Error:" + Get-Content $errorFile + exit 1 + } + + $jsonContent = $response.Content | ConvertFrom-Json + if ($jsonContent) { + Write-Output "Good: /health JSON content is not empty: $jsonContent" + } else { + Write-Output "Error: /health JSON content is empty" + Write-Host "Standard Output:" + Get-Content $outputFile + + Write-Host "Standard Error:" + Get-Content $errorFile + exit 1 + } + + Write-Host "Close the server process" + + function Kill-Tree { + Param([int]$ppid) + Get-CimInstance Win32_Process | Where-Object { $_.ParentProcessId -eq $ppid } | ForEach-Object { Kill-Tree $_.ProcessId } + Stop-Process -Id $ppid + } + Kill-Tree $serverProcess.Id + + + + diff --git a/.github/workflows/server_installer_windows_latest.yml b/.github/workflows/server_installer_windows_latest.yml new file mode 100644 index 00000000..cdea7c63 --- /dev/null +++ b/.github/workflows/server_installer_windows_latest.yml @@ -0,0 +1,170 @@ +name: Server Installer Windows-Latest Build and Test + +on: + push: + branches: ["main"] + tags: + - v* + pull_request: + branches: ["main"] + workflow_dispatch: + +jobs: + make-server-installer: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Install NSIS + shell: PowerShell + run: | + # Download NSIS installer + Invoke-WebRequest -UserAgent "Wget" -Uri "https://sourceforge.net/projects/nsis/files/NSIS%203/3.10/nsis-3.10-setup.exe" -OutFile "nsis.exe" + + # Install NSIS + Start-Process nsis.exe -ArgumentList '/S' -Wait + + - name: Verify NSIS installation + shell: PowerShell + run: | + # Check if NSIS is installed + & 'C:\Program Files (x86)\NSIS\makensis.exe' /VERSION + + - name: Build the Lemonade Server installer + shell: PowerShell + run: | + cd installer + & 'C:\Program Files (x86)\NSIS\makensis.exe' 'Installer.nsi' + + if (Test-Path "Lemonade_Server_Installer.exe") { + Write-Host "Lemonade_Server_Installer.exe has been created successfully." + } else { + Write-Host "Lemonade_Server_Installer.exe was not found." + exit 1 + } + + - name: Upload Installer + uses: actions/upload-artifact@v4 + if: always() + with: + name: LemonadeServerInstaller + path: | + installer\Lemonade_Server_Installer.exe + + - name: Attempt to install Lemonade Server using installer + shell: cmd + run: | + cd installer + Lemonade_Server_Installer.exe /S + + - name: Ensure the Lemonade serer works properly + shell: pwsh + run: | + Write-Host "Use a function to determine the underlying command from the lemonade server shortcut" + function Get-ShortcutTarget { + param ( + [string]$shortcutPath + ) + $shell = New-Object -ComObject WScript.Shell + $shortcut = $shell.CreateShortcut($shortcutPath) + $targetPath = $shortcut.TargetPath + $arguments = $shortcut.Arguments + return "$targetPath $arguments" + } + + Write-Host "ls of install directory to make sure the server is there" + ls "$HOME\AppData\Local\lemonade_server" + + $shortcutPath = "$HOME\AppData\Local\lemonade_server\lemonade-server.lnk" + $fullCommand = Get-ShortcutTarget -shortcutPath $shortcutPath + + Write-Host "Server shortcut full command: $fullCommand" + + $quotedCommand = "`"$fullCommand`"" + + $outputFile = "output.log" + $errorFile = "error.log" + $serverProcess = Start-Process -FilePath "cmd.exe" -ArgumentList "/C $quotedCommand" -RedirectStandardOutput $outputFile -RedirectStandardError $errorFile -PassThru -NoNewWindow + + Write-Host "Wait for 30 seconds to let the server come up" + Start-Sleep -Seconds 30 + + Write-Host "Check if server process successfully launched" + $serverRunning = Get-Process -Id $serverProcess.Id -ErrorAction SilentlyContinue + if (-not $serverRunning) { + Write-Host "Error: Server process isn't running, even though we just tried to start it!" + Write-Host "Standard Output:" + Get-Content $outputFile + + Write-Host "Standard Error:" + Get-Content $errorFile + exit 1 + } else { + Write-Host "Server process is alive." + } + + Write-Host "Wait for the server port to come up" + while ($true) { + + $llmPortCheck = Test-NetConnection -ComputerName 127.0.0.1 -Port 8000 + if (-not $llmPortCheck.TcpTestSucceeded) { + Write-Host "LLM server is not yet running on port 8000!" + Write-Host "Standard Output:" + Get-Content $outputFile + + Write-Host "Standard Error:" + Get-Content $errorFile + } else { + Write-Host "LLM server is running on port 8000." + break + } + + Start-Sleep -Seconds 30 + } + + Write-Host "Checking the /health endpoint" + $response = Invoke-WebRequest -Uri http://localhost:8000/api/v0/health -UseBasicParsing + + if ($response.StatusCode -eq 200) { + Write-Output "Good: /health status code is 200" + } else { + Write-Output "Error: /health status code is not 200" + Write-Host "Standard Output:" + Get-Content $outputFile + + Write-Host "Standard Error:" + Get-Content $errorFile + exit 1 + } + + $jsonContent = $response.Content | ConvertFrom-Json + if ($jsonContent) { + Write-Output "Good: /health JSON content is not empty: $jsonContent" + } else { + Write-Output "Error: /health JSON content is empty" + Write-Host "Standard Output:" + Get-Content $outputFile + + Write-Host "Standard Error:" + Get-Content $errorFile + exit 1 + } + + Write-Host "Close the server process" + + function Kill-Tree { + Param([int]$ppid) + Get-CimInstance Win32_Process | Where-Object { $_.ParentProcessId -eq $ppid } | ForEach-Object { Kill-Tree $_.ProcessId } + Stop-Process -Id $ppid + } + Kill-Tree $serverProcess.Id + + - name: Release + uses: softprops/action-gh-release@v2 + if: startsWith(github.ref, 'refs/tags/v') + with: + files: installer/Lemonade_Server_Installer.exe + + + + diff --git a/.github/workflows/test_lemonade.yml b/.github/workflows/test_lemonade.yml index b7bdbbe3..45f626b2 100644 --- a/.github/workflows/test_lemonade.yml +++ b/.github/workflows/test_lemonade.yml @@ -46,13 +46,6 @@ jobs: run: | pylint src/lemonade --rcfile .pylintrc --disable E0401 pylint examples --rcfile .pylintrc --disable E0401,E0611 --jobs=1 - - name: Test HF+CPU server - if: runner.os == 'Windows' - timeout-minutes: 10 - uses: ./.github/actions/server-testing - with: - conda_env: -n lemon - load_command: -i facebook/opt-125m huggingface-load - name: Run lemonade tests shell: bash -el {0} run: | @@ -63,7 +56,11 @@ jobs: python test/lemonade/llm_api.py - # Test high-level LEAP APIs - python examples/lemonade/leap_basic.py - python examples/lemonade/leap_streaming.py + # Test high-level APIs + python examples/lemonade/api_basic.py + python examples/lemonade/api_streaming.py + + # Test server + python test/lemonade/server.py + diff --git a/.github/workflows/test_lemonade_oga_cpu.yml b/.github/workflows/test_lemonade_oga_cpu.yml index 52474ee8..26739d80 100644 --- a/.github/workflows/test_lemonade_oga_cpu.yml +++ b/.github/workflows/test_lemonade_oga_cpu.yml @@ -53,15 +53,7 @@ jobs: # Test low-level APIs python test/lemonade/oga_cpu_api.py - # Test high-level LEAP APIs - python examples/lemonade/leap_oga_cpu.py - python examples/lemonade/leap_oga_cpu_streaming.py - - name: Test OGA+CPU server - if: runner.os == 'Windows' - timeout-minutes: 10 - uses: ./.github/actions/server-testing - with: - conda_env: -n lemon - load_command: -i TinyPixel/small-llama2 oga-load --device cpu --dtype int4 - hf_token: "${{ secrets.HUGGINGFACE_ACCESS_TOKEN }}" # Required by OGA model_builder in OGA 0.4.0 but not future versions + # Test high-level APIs + python examples/lemonade/api_oga_cpu.py + python examples/lemonade/api_oga_cpu_streaming.py diff --git a/NOTICE.md b/NOTICE.md index 90b48240..602a906c 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -2,6 +2,8 @@ PORTIONS LICENSED AS FOLLOWS \> TurnkeyML used code from the [MLAgility](https://github.com/groq/mlagility) and [GroqFlow](https://github.com/groq/groqflow) projects as a starting point. Much of that code was refactored, improved, or replaced by the time TurnkeyML was published. +\> TurnkeyML uses the [Microsoft lemon emoji](https://github.com/microsoft/fluentui-emoji) as an icon for the lemoande tool. + >The MIT License > >Copyright 2023 Groq Inc. diff --git a/README.md b/README.md index 9780829b..73c3ecff 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ We are on a mission to make it easy to use the most important tools in the ONNX ecosystem. TurnkeyML accomplishes this by providing no-code CLIs and low-code APIs for both general ONNX workflows with `turnkey` as well as LLMs with `lemonade`. -| [**Lemonade**](https://github.com/onnx/turnkeyml/blob/main/docs/lemonade/getting_started.md) | [**Turnkey**](https://github.com/onnx/turnkeyml/blob/main/docs/turnkey/getting_started.md) | +| [**Lemonade SDK**](https://github.com/onnx/turnkeyml/blob/main/docs/lemonade/getting_started.md) | [**Turnkey**](https://github.com/onnx/turnkeyml/blob/main/docs/turnkey/getting_started.md) | |:----------------------------------------------: |:-----------------------------------------------------------------: | | Serve and benchmark LLMs on CPU, GPU, and NPU.
[Click here to get started with `lemonade`.](https://github.com/onnx/turnkeyml/blob/main/docs/lemonade/getting_started.md) | Export and optimize ONNX models for CNNs and Transformers.
[Click here to get started with `turnkey`.](https://github.com/onnx/turnkeyml/blob/main/docs/turnkey/getting_started.md) | | | | diff --git a/docs/lemonade/getting_started.md b/docs/lemonade/getting_started.md index 9b2456fd..bcd337a2 100644 --- a/docs/lemonade/getting_started.md +++ b/docs/lemonade/getting_started.md @@ -1,19 +1,11 @@ -# Lemonade +# Lemonade SDK -Welcome to the project page for `lemonade` the Turnkey LLM Aide! - -1. [Install](#install) -1. [CLI Commands](#cli-commands) - - [Syntax](#syntax) - - [Chatting](#chatting) - - [Accuracy](#accuracy) - - [Benchmarking](#benchmarking) - - [Memory Usage](#memory-usage) - - [Serving](#serving) -1. [API Overview](#api) -1. [Code Organization](#code-organization) -1. [Contributing](#contributing) +The `lemonade` SDK provides everything needed to get up and running quickly with LLMs on OnnxRuntime GenAI (OGA). +- [Quick installation from PyPI](#install). +- [CLI with tools for prompting, benchmarking, and accuracy tests](#cli-commands). +- [REST API with OpenAI compatibility](#serving). +- [Python API based on `from_pretrained()` for easy integration with Python apps](#api). # Install @@ -85,9 +77,9 @@ Can be read like this: The `lemonade -h` command will show you which options and Tools are available, and `lemonade TOOL -h` will tell you more about that specific Tool. -## Chatting +## Prompting -To chat with your LLM try: +To prompt your LLM try: OGA iGPU: ```bash @@ -163,33 +155,27 @@ contains a figure plotting the memory usage over the build time. Learn more by ## Serving -You can launch a WebSocket server for your LLM with: - -OGA iGPU: -```bash - lemonade -i microsoft/Phi-3-mini-4k-instruct oga-load --device igpu --dtype int4 serve -``` +You can launch an OpenAI-compatible server with: -Hugging Face: ```bash - lemonade -i facebook/opt-125m huggingface-load serve + lemonade serve ``` -Once the server has launched, you can connect to it from your own application, or interact directly by following the on-screen instructions to open a basic web app. +Visit the [server spec](https://github.com/onnx/turnkeyml/blob/main/docs/lemonade/server_spec.md) to learn more about the endpoints provided. # API Lemonade is also available via API. -## LEAP APIs +## High-Level APIs -The lemonade enablement platform (LEAP) API abstracts loading models from any supported framework (e.g., Hugging Face, OGA) and backend (e.g., CPU, iGPU, Hybrid). This makes it easy to integrate lemonade LLMs into Python applications. +The high-level lemonade API abstracts loading models from any supported framework (e.g., Hugging Face, OGA) and backend (e.g., CPU, iGPU, Hybrid) using the popular `from_pretrained()` function. This makes it easy to integrate lemonade LLMs into Python applications. OGA iGPU: ```python -from lemonade import leap +from lemonade.api import from_pretrained -model, tokenizer = leap.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct", recipe="oga-igpu") +model, tokenizer = from_pretrained("Qwen/Qwen2.5-0.5B-Instruct", recipe="oga-igpu") input_ids = tokenizer("This is my prompt", return_tensors="pt").input_ids response = model.generate(input_ids, max_new_tokens=30) @@ -197,7 +183,7 @@ response = model.generate(input_ids, max_new_tokens=30) print(tokenizer.decode(response[0])) ``` -You can learn more about the LEAP APIs [here](https://github.com/onnx/turnkeyml/tree/main/examples/lemonade). +You can learn more about the high-level APIs [here](https://github.com/onnx/turnkeyml/tree/main/examples/lemonade). ## Low-Level API @@ -207,13 +193,13 @@ Here's a quick example of how to prompt a Hugging Face LLM using the low-level A ```python import lemonade.tools.torch_llm as tl -import lemonade.tools.chat as cl +import lemonade.tools.prompt as pt from turnkeyml.state import State state = State(cache_dir="cache", build_name="test") state = tl.HuggingfaceLoad().run(state, input="facebook/opt-125m") -state = cl.Prompt().run(state, prompt="hi", max_new_tokens=15) +state = pt.Prompt().run(state, prompt="hi", max_new_tokens=15) print("Response:", state.response) ``` diff --git a/docs/lemonade/server_spec.md b/docs/lemonade/server_spec.md index 7b8e949c..b8530e6e 100644 --- a/docs/lemonade/server_spec.md +++ b/docs/lemonade/server_spec.md @@ -1,35 +1,157 @@ -# Lemonade Server Spec (Preview) +# Lemonade Server Spec -> This is a preview release. The API specification is subject to change. +The `lemonade` SDK provides a standards-compliant server process that provides a REST API to enable communication with other applications. Right now, the [key endpoints of the OpenAI API](#openai-compatible-endpoints) are available. Our plan is to add more OpenAI endpoints, as well as Ollama-compatible endpoints, in the near future. -This spec was inspired by the [LM Studio REST API](https://lmstudio.ai/docs/api-reference/rest-api), [Ollama API](https://github.com/ollama/ollama/blob/main/docs/api.md), and [OpenAI API](https://platform.openai.com/docs/api-reference/introduction). +We are also actively investigating and developing [additional endpoints](#additional-endpoints) that will improve the experience of local applications. -This spec focuses on enabling client applications by extending existing cloud-focused APIs (e.g., OpenAI) to also include the ability to load and unload models before completion requests are made. These extensions allow for a greater degree of UI/UX responsiveness in native applications by allowing applications to: +## Endpoints Overview + +### OpenAI-Compatible Endpoints +- POST `/api/v0/chat/completions` - Chat Completions (messages -> completion) +- GET `/api/v0/models` - List available models + +### Additional Endpoints + +> 🚧 These additional endpoints are a preview that is under active development. The API specification is subject to change. + +These additional endpoints were inspired by the [LM Studio REST API](https://lmstudio.ai/docs/api-reference/rest-api), [Ollama API](https://github.com/ollama/ollama/blob/main/docs/api.md), and [OpenAI API](https://platform.openai.com/docs/api-reference/introduction). + +They focus on enabling client applications by extending existing cloud-focused APIs (e.g., OpenAI) to also include the ability to load and unload models before completion requests are made. These extensions allow for a greater degree of UI/UX responsiveness in native applications by allowing applications to: - Pre-load models at UI-loading-time, as opposed to completion-request time. - Load models from the local system that were downloaded by other applications (i.e., a common system-wide models cache). - Unload models to save memory space. -## API Endpoints - +The additional endpoints under development are: - POST `/api/v0/completions` - Text Completions (prompt -> completion) - POST `/api/v0/load` - Load a model - POST `/api/v0/unload` - Unload a model - POST `/api/v0/params` - Set generation parameters - GET `/api/v0/health` - Check server health - GET `/api/v0/stats` - Performance statistics from the last request -- GET `/api/v0/models` - List available models > 🚧 We are in the process of developing this interface. Let us know what's important to you on Github or by email (turnkeyml at amd dot com). -## Start the REST API server +## Start the REST API Server + +> **NOTE:** This server is intended for use on local systems only. Do not expose the server port to the open internet. -First, install lemonade with your desired backend (e.g., `pip install lemonade[llm-oga-cpu]`). Then, run the following command to start the server: +First, install lemonade with your desired backend (e.g., `pip install lemonade[llm]`). Then, run the following command to start the server: ```bash -lemonade server-preview +lemonade serve ``` -## Endpoints +## OpenAI-Compatible Endpoints + + +### `POST /api/v0/chat/completions` ![Status](https://img.shields.io/badge/status-partially_available-green) + +Chat Completions API. You provide a list of messages and receive a streamed completion. This API will also load the model if it is not already loaded. + +### Parameters + +| Parameter | Required | Description | Status | +|-----------|----------|-------------|--------| +| `messages` | Yes | Array of messages in the conversation. Each message should have a `role` ("user" or "assistant") and `content` (the message text). | ![Status](https://img.shields.io/badge/available-green) | +| `model` | Yes | The model to use for the completion. | ![Status](https://img.shields.io/badge/available-green) | +| `stream` | No | If true, tokens will be sent as they are generated. If false, the response will be sent as a single message once complete. Defaults to false. | ![Status](https://img.shields.io/badge/available-green) | +| `logprobs` | No | Include log probabilities of the output tokens. If true, returns the log probability of each output token. Defaults to false. | ![Status](https://img.shields.io/badge/WIP-yellow) | + + +### Example request + +```bash +curl -X POST http://localhost:8000/api/v0/chat/completions ^ + -H "Content-Type: application/json" ^ + -d "{ + \"model\": \"Llama-3.2-1B-Instruct-Hybrid\", + \"messages\": [ + {\"role\": \"user\", \"content\": \"What is the population of Paris?\"} + ], + \"stream\": true + }" + +``` +*Hint: To try, "Paste as One Line" in Windows `cmd`.* + +### Response format + +For non-streaming responses: +```json +{ + "id": "0", + "object": "chat.completion", + "created": , + "model": "Llama-3.2-1B-Instruct-Hybrid", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "Paris has a population of approximately 2.2 million people in the city proper." + }, + "logprobs": { + "tokens": ["Paris", " has", " a", " population", ...], + "token_logprobs": [-0.12, -0.05, -0.02, -0.15, ...] + }, + "finish_reason": "stop" + }] +} +``` + +For streaming responses, the API returns a stream of server-sent events: +```json +{ + "id": "0", + "object": "chat.completion.chunk", + "created": , + "model": "Llama-3.2-1B-Instruct-Hybrid", + "choices": [{ + "index": 0, + "delta": { + "role": "assistant", + "content": "Paris" + } + }] +} +``` + +### `GET /api/v0/models` ![Status](https://img.shields.io/badge/status-fully_available-green) + +Returns a list of key models available on the server in an OpenAI-compatible format. This list is curated based on what works best for Ryzen AI Hybrid. Additional models can be loaded via the `/api/v0/load` endpoint by specifying the Hugging Face checkpoint. + +### Parameters + +This endpoint does not take any parameters. + +### Example request + +```bash +curl http://localhost:8000/api/v0/models +``` + +### Response format + +```json +{ + "object": "list", + "data": [ + { + "id": "", + "object": "model", + "created": , + "owned_by": "" + }, + { + "id": "", + "object": "model", + "created": , + "owned_by": "" + } + ] +} +``` + +## Additional Endpoints ### `POST /api/v0/completions` ![Status](https://img.shields.io/badge/status-partially_available-green) @@ -40,19 +162,19 @@ Text Completions API. You provide a prompt and receive a streamed completion. Th | Parameter | Required | Description | Status | |-----------|----------|-------------|--------| | `prompt` | Yes | The prompt to use for the completion. | ![Status](https://img.shields.io/badge/available-green) | -| `model` | No | The model to use for the completion. If not specified, the server will use the default loaded model. | ![Status](https://img.shields.io/badge/available-green) | +| `model` | Yes | The model to use for the completion. | ![Status](https://img.shields.io/badge/available-green) | | All other params of `/api/v0/load` | No | Detailed loading options as defined in the `/api/v0/load` endpoint. | ![Status](https://img.shields.io/badge/WIP-yellow) | | All other params of `/api/v0/params` | No | Detailed generation options as defined in the `/api/v0/params` endpoint. | ![Status](https://img.shields.io/badge/WIP-yellow) | ### Example request ```bash -curl http://localhost:1234/api/v0/completions \ - -H "Content-Type: application/json" \ - -d '{ - "model": "", - "prompt": "the meaning of life is", - }' +curl -X POST http://localhost:8000/api/v0/completions ^ + -H "Content-Type: application/json" ^ + -d "{ + \"model\": \"\", + \"prompt\": \"the meaning of life is\" + }" ``` ### Response format @@ -73,16 +195,15 @@ Explicitly load a model. This is useful to ensure that the model is loaded befor |-----------|----------|-------------| | `model` | Yes | HuggingFace checkpoint to load. | | `device` | No | Device to load the model on. Defaults to `hybrid`. | -| `dtype` | No | Data type to load the model on. Defaults to `int4`. | | `cache_dir` | No | Parent directory where models are stored. Defaults to `~/.cache/lemonade`. | ### Example request ```bash -curl http://localhost:1234/api/v0/load \ +curl http://localhost:8000/api/v0/load \ -H "Content-Type: application/json" \ -d '{ - "model": "", + "model": "", "cache_dir": "/Users/your_username/models" }' ``` @@ -109,7 +230,7 @@ This endpoint does not take any parameters. ### Example request ```bash -curl http://localhost:1234/api/v0/unload +curl http://localhost:8000/api/v0/unload ``` ### Response format @@ -139,7 +260,7 @@ Set the generation parameters for text completion. These parameters will persist ### Example request ```bash -curl http://localhost:1234/api/v0/params \ +curl http://localhost:8000/api/v0/params \ -H "Content-Type: application/json" \ -d '{ "temperature": 0.8, @@ -177,7 +298,7 @@ This endpoint does not take any parameters. ### Example request ```bash -curl http://localhost:1234/api/v0/health +curl http://localhost:8000/api/v0/health ``` ### Response format @@ -185,7 +306,7 @@ curl http://localhost:1234/api/v0/health ```json { "status": "ok", - "model_loaded": "" + "model_loaded": "" } ``` ### `GET /api/v0/stats` ![Status](https://img.shields.io/badge/status-fully_available-green) @@ -199,7 +320,7 @@ This endpoint does not take any parameters. ### Example request ```bash -curl http://localhost:1234/api/v0/stats +curl http://localhost:8000/api/v0/stats ``` ### Response format @@ -213,42 +334,3 @@ curl http://localhost:1234/api/v0/stats "decode_token_times": [0.01, 0.02, 0.03, 0.04, 0.05] } ``` - -### `GET /api/v0/models` ![Status](https://img.shields.io/badge/status-in_development-yellow) - -List all available models. - -### Parameters - -| Parameter | Required | Description | -|-----------|----------|-------------| -| `cache_dir` | No | Parent directory where models are stored. Defaults to `~/.cache/lemonade`. | - -### Example request - -```bash -curl http://localhost:1234/api/v0/models \ - -H "Content-Type: application/json" \ - -d '{ - "cache_dir": "/Users/your_username/models" - }' -``` - -### Response format - -```json -{ - "data": [ - { - "checkpoint": "", - "device": "cpu", - "dtype": "bfloat16", - }, - { - "checkpoint": "", - "device": "cpu", - "dtype": "bfloat16", - } - ] -} -``` diff --git a/examples/lemonade/README.md b/examples/lemonade/README.md index df671737..e450e61a 100644 --- a/examples/lemonade/README.md +++ b/examples/lemonade/README.md @@ -1,18 +1,16 @@ # Lemonade Examples -This folder contains examples of how to use `lemonade` via the high-level LEAP APIs. These APIs make it easy to load a model, generate responses, and also show how to stream those responses. +This folder contains examples of how to use `lemonade` via the high-level APIs. These APIs make it easy to load a model, generate responses, and also show how to stream those responses. -The `demos/` folder also contains some higher-level application demos of the LEAP APIs. Learn more in `demos/README.md`. +The `demos/` folder also contains some higher-level application demos of the APIs. Learn more in `demos/README.md`. -## LEAP Examples - -This table shows which LEAP examples are available: +This table shows which API examples are available: | Framework | CPU | GPU | NPU | Hybrid | |----------------------------|---------------------------|------------------|-----------------|--------------------| -| Huggingface | leap_basic.py | - | - | - | -| OGA | leap_oga_cpu.py | leap_oga_igpu.py | leap_oga_npu.py | leap_oga_hybrid.py | -| Huggingface with streaming | leap_streaming.py | - | - | - | -| OGA with streaming | leap_oga_cpu_streaming.py | leap_oga_igpu_streaming.py | leap_oga_npu_streaming.py | leap_oga_hybrid_streaming.py | +| Huggingface | api_basic.py | - | - | - | +| OGA | api_oga_cpu.py | api_oga_igpu.py | api_oga_npu.py | api_oga_hybrid.py | +| Huggingface with streaming | api_streaming.py | - | - | - | +| OGA with streaming | api_oga_cpu_streaming.py | api_oga_igpu_streaming.py | api_oga_npu_streaming.py | api_oga_hybrid_streaming.py | -To run a LEAP example, first set up a conda environment with the appropriate framework and backend support. Then run the scripts with a command like `python leap_basic.py`. \ No newline at end of file +To run an API example, first set up a conda environment with the appropriate framework and backend support. Then run the scripts with a command like `python api_basic.py`. \ No newline at end of file diff --git a/examples/lemonade/api_basic.py b/examples/lemonade/api_basic.py index 330d271f..00826b9c 100644 --- a/examples/lemonade/api_basic.py +++ b/examples/lemonade/api_basic.py @@ -1,5 +1,5 @@ """ -This example demonstrates how to use the LEAP API to load a model for +This example demonstrates how to use the lemonade API to load a model for inference on CPU using the hf-cpu recipe, and then use it to generate the response to a prompt. @@ -8,9 +8,9 @@ hf-dgpu. """ -from lemonade import leap +from lemonade.api import from_pretrained -model, tokenizer = leap.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct", recipe="hf-cpu") +model, tokenizer = from_pretrained("Qwen/Qwen2.5-0.5B-Instruct", recipe="hf-cpu") input_ids = tokenizer("This is my prompt", return_tensors="pt").input_ids response = model.generate(input_ids, max_new_tokens=30) diff --git a/examples/lemonade/api_oga_cpu.py b/examples/lemonade/api_oga_cpu.py index 9b9d5393..ef9f1aa8 100644 --- a/examples/lemonade/api_oga_cpu.py +++ b/examples/lemonade/api_oga_cpu.py @@ -1,5 +1,5 @@ """ -This example demonstrates how to use the LEAP API to load a model for +This example demonstrates how to use the lemonade API to load a model for inference on CPU via OnnxRuntime-Genai (OGA) using the oga-cpu recipe, and then use it to generate the response to a prompt. @@ -8,9 +8,9 @@ https://github.com/onnx/turnkeyml/blob/main/docs/lemonade/getting_started.md#install-onnxruntime-genai """ -from lemonade import leap +from lemonade.api import from_pretrained -model, tokenizer = leap.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct", recipe="oga-cpu") +model, tokenizer = from_pretrained("Qwen/Qwen2.5-0.5B-Instruct", recipe="oga-cpu") input_ids = tokenizer("This is my prompt", return_tensors="pt").input_ids response = model.generate(input_ids, max_new_tokens=30) diff --git a/examples/lemonade/api_oga_cpu_streaming.py b/examples/lemonade/api_oga_cpu_streaming.py index b88c05f4..32548b44 100644 --- a/examples/lemonade/api_oga_cpu_streaming.py +++ b/examples/lemonade/api_oga_cpu_streaming.py @@ -1,5 +1,5 @@ """ -This example demonstrates how to use the LEAP API to load a model for +This example demonstrates how to use the lemonade API to load a model for inference on CPU via OnnxRuntime-GenAI using the oga-cpu recipe, and then use a thread to generate a streaming the response to a prompt. @@ -12,10 +12,10 @@ """ from threading import Thread -from lemonade import leap +from lemonade.api import from_pretrained from lemonade.tools.ort_genai.oga import OrtGenaiStreamer -model, tokenizer = leap.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct", recipe="oga-cpu") +model, tokenizer = from_pretrained("Qwen/Qwen2.5-0.5B-Instruct", recipe="oga-cpu") input_ids = tokenizer("This is my prompt", return_tensors="pt").input_ids diff --git a/examples/lemonade/api_oga_hybrid.py b/examples/lemonade/api_oga_hybrid.py index da47e2c9..3ab05011 100644 --- a/examples/lemonade/api_oga_hybrid.py +++ b/examples/lemonade/api_oga_hybrid.py @@ -1,5 +1,5 @@ """ -This example demonstrates how to use the LEAP API to load a model for +This example demonstrates how to use the lemonade API to load a model for inference on Ryzen AI hybrid mode (NPU and iGPU together) via OnnxRuntime-Genai (OGA) using the oga-hybrid recipe, and then use it to generate the response to a prompt. @@ -8,9 +8,9 @@ https://github.com/onnx/turnkeyml/blob/main/docs/lemonade/getting_started.md#install-onnxruntime-genai """ -from lemonade import leap +from lemonade.api import from_pretrained -model, tokenizer = leap.from_pretrained( +model, tokenizer = from_pretrained( "amd/Llama-3.2-1B-Instruct-awq-g128-int4-asym-fp16-onnx-hybrid", recipe="oga-hybrid" ) diff --git a/examples/lemonade/api_oga_hybrid_streaming.py b/examples/lemonade/api_oga_hybrid_streaming.py index 0b133f16..4abc89cb 100644 --- a/examples/lemonade/api_oga_hybrid_streaming.py +++ b/examples/lemonade/api_oga_hybrid_streaming.py @@ -1,5 +1,5 @@ """ -This example demonstrates how to use the LEAP API to load a model for +This example demonstrates how to use the lemonade API to load a model for inference on Ryzen AI hybrid mode (NPU and iGPU together) via OnnxRuntime-GenAI using the oga-cpu recipe, and then use a thread to generate a streaming the response to a prompt. @@ -13,10 +13,10 @@ """ from threading import Thread -from lemonade import leap +from lemonade.api import from_pretrained from lemonade.tools.ort_genai.oga import OrtGenaiStreamer -model, tokenizer = leap.from_pretrained( +model, tokenizer = from_pretrained( "amd/Llama-3.2-1B-Instruct-awq-g128-int4-asym-fp16-onnx-hybrid", recipe="oga-hybrid" ) diff --git a/examples/lemonade/api_oga_igpu.py b/examples/lemonade/api_oga_igpu.py index 5891e45d..c766dc97 100644 --- a/examples/lemonade/api_oga_igpu.py +++ b/examples/lemonade/api_oga_igpu.py @@ -1,5 +1,5 @@ """ -This example demonstrates how to use the LEAP API to load a model for +This example demonstrates how to use the lemonade API to load a model for inference on integrated GPUs (iGPUs) via OnnxRuntime-Genai (OGA) using the oga-igpu recipe, and then use it to generate the response to a prompt. @@ -8,9 +8,9 @@ https://github.com/onnx/turnkeyml/blob/main/docs/lemonade/getting_started.md#install-onnxruntime-genai """ -from lemonade import leap +from lemonade.api import from_pretrained -model, tokenizer = leap.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct", recipe="oga-igpu") +model, tokenizer = from_pretrained("Qwen/Qwen2.5-0.5B-Instruct", recipe="oga-igpu") input_ids = tokenizer("This is my prompt", return_tensors="pt").input_ids response = model.generate(input_ids, max_new_tokens=30) diff --git a/examples/lemonade/api_oga_igpu_streaming.py b/examples/lemonade/api_oga_igpu_streaming.py index 58416934..7783217a 100644 --- a/examples/lemonade/api_oga_igpu_streaming.py +++ b/examples/lemonade/api_oga_igpu_streaming.py @@ -1,5 +1,5 @@ """ -This example demonstrates how to use the LEAP API to load a model for +This example demonstrates how to use the lemonade API to load a model for inference on integrated GPUs (iGPUs) via OnnxRuntime-GenAI using the oga-igpu recipe, and then use a thread to generate a streaming the response to a prompt. @@ -12,10 +12,10 @@ """ from threading import Thread -from lemonade import leap +from lemonade.api import from_pretrained from lemonade.tools.ort_genai.oga import OrtGenaiStreamer -model, tokenizer = leap.from_pretrained( +model, tokenizer = from_pretrained( "Qwen/Qwen2.5-0.5B-Instruct", recipe="oga-igpu", ) diff --git a/examples/lemonade/api_oga_npu.py b/examples/lemonade/api_oga_npu.py index d162b8d9..5c1f7ed2 100644 --- a/examples/lemonade/api_oga_npu.py +++ b/examples/lemonade/api_oga_npu.py @@ -1,5 +1,5 @@ """ -This example demonstrates how to use the LEAP API to load a model for +This example demonstrates how to use the lemonade API to load a model for inference on Ryzen AI NPU via OnnxRuntime-Genai (OGA) using the oga-npu recipe, and then use it to generate the response to a prompt. @@ -8,9 +8,9 @@ https://github.com/onnx/turnkeyml/blob/main/docs/lemonade/getting_started.md#install-onnxruntime-genai """ -from lemonade import leap +from lemonade.api import from_pretrained -model, tokenizer = leap.from_pretrained( +model, tokenizer = from_pretrained( "amd/Phi-3.5-mini-instruct-awq-g128-int4-asym-bf16-onnx-ryzen-strix", recipe="oga-npu", ) diff --git a/examples/lemonade/api_oga_npu_streaming.py b/examples/lemonade/api_oga_npu_streaming.py index 1b5e396e..f387a2c2 100644 --- a/examples/lemonade/api_oga_npu_streaming.py +++ b/examples/lemonade/api_oga_npu_streaming.py @@ -1,5 +1,5 @@ """ -This example demonstrates how to use the LEAP API to load a model for +This example demonstrates how to use the lemonade API to load a model for inference on Ryzen AI NPU via OnnxRuntime-GenAI using the oga-npu recipe, and then use a thread to generate a streaming the response to a prompt. @@ -12,10 +12,10 @@ """ from threading import Thread -from lemonade import leap +from lemonade.api import from_pretrained from lemonade.tools.ort_genai.oga import OrtGenaiStreamer -model, tokenizer = leap.from_pretrained( +model, tokenizer = from_pretrained( "amd/Phi-3.5-mini-instruct-awq-g128-int4-asym-bf16-onnx-ryzen-strix", recipe="oga-npu", ) diff --git a/examples/lemonade/api_streaming.py b/examples/lemonade/api_streaming.py index 2c13b1b3..cabeb3fc 100644 --- a/examples/lemonade/api_streaming.py +++ b/examples/lemonade/api_streaming.py @@ -1,5 +1,5 @@ """ -This example demonstrates how to use the LEAP API to load a model for +This example demonstrates how to use the lemonade API to load a model for inference on CPU using the hf-cpu recipe, and then use a thread to generate a streaming the response to a prompt. @@ -9,9 +9,9 @@ from threading import Thread from transformers import TextIteratorStreamer -from lemonade import leap +from lemonade.api import from_pretrained -model, tokenizer = leap.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct", recipe="hf-cpu") +model, tokenizer = from_pretrained("Qwen/Qwen2.5-0.5B-Instruct", recipe="hf-cpu") input_ids = tokenizer("This is my prompt", return_tensors="pt").input_ids diff --git a/examples/lemonade/demos/chat/chat_hybrid.py b/examples/lemonade/demos/chat/chat_hybrid.py index d4e3c8f1..d1a11b1b 100644 --- a/examples/lemonade/demos/chat/chat_hybrid.py +++ b/examples/lemonade/demos/chat/chat_hybrid.py @@ -1,14 +1,14 @@ import sys from threading import Thread, Event from transformers import StoppingCriteriaList -from lemonade.tools.chat import StopOnEvent -from lemonade import leap +from lemonade.tools.serve import StopOnEvent +from lemonade.api import from_pretrained from lemonade.tools.ort_genai.oga import OrtGenaiStreamer def main(): - model, tokenizer = leap.from_pretrained( + model, tokenizer = from_pretrained( "amd/Llama-3.2-1B-Instruct-awq-g128-int4-asym-fp16-onnx-hybrid", recipe="oga-hybrid", ) diff --git a/examples/lemonade/demos/chat/chat_start.py b/examples/lemonade/demos/chat/chat_start.py index a094c838..c2637875 100644 --- a/examples/lemonade/demos/chat/chat_start.py +++ b/examples/lemonade/demos/chat/chat_start.py @@ -3,14 +3,14 @@ from queue import Queue from time import sleep from transformers import StoppingCriteriaList -from lemonade.tools.chat import StopOnEvent +from lemonade.tools.serve import StopOnEvent class TextStreamer: """ Imitates a queue for streaming text from one thread to another. - Not needed once we integrate with LEAP. + Not needed once we integrate with the lemonade API. """ def __init__(self): @@ -40,7 +40,7 @@ def generate_placeholder( """ Imitates an LLM's generate function by streaming text to a queue. - Not needed once we integrate with LEAP. + Not needed once we integrate with the lemonade API. """ # pylint: disable=line-too-long diff --git a/examples/lemonade/demos/search/search_hybrid.py b/examples/lemonade/demos/search/search_hybrid.py index b8fe9fc7..7e121064 100644 --- a/examples/lemonade/demos/search/search_hybrid.py +++ b/examples/lemonade/demos/search/search_hybrid.py @@ -1,9 +1,9 @@ import sys from threading import Thread, Event from transformers import StoppingCriteriaList -from lemonade import leap +from lemonade.api import from_pretrained from lemonade.tools.ort_genai.oga import OrtGenaiStreamer -from lemonade.tools.chat import StopOnEvent +from lemonade.tools.serve import StopOnEvent employee_handbook = """ 1. You will work very hard every day.\n @@ -28,7 +28,7 @@ def system_prompt(user_prompt): def main(): # Load LLaMA-3.2 1B model on Ryzen AI Hybrid - model, tokenizer = leap.from_pretrained( + model, tokenizer = from_pretrained( "amd/Llama-3.2-1B-Instruct-awq-g128-int4-asym-fp16-onnx-hybrid", recipe="oga-hybrid", ) diff --git a/examples/lemonade/demos/search/search_start.py b/examples/lemonade/demos/search/search_start.py index 8249e291..dc697b4c 100644 --- a/examples/lemonade/demos/search/search_start.py +++ b/examples/lemonade/demos/search/search_start.py @@ -3,7 +3,7 @@ from queue import Queue from time import sleep from transformers import StoppingCriteriaList -from lemonade.tools.chat import StopOnEvent +from lemonade.tools.serve import StopOnEvent employee_handbook = """ @@ -18,7 +18,7 @@ class TextStreamer: """ Imitates a queue for streaming text from one thread to another. - Not needed once we integrate with LEAP. + Not needed once we integrate with the lemonade API. """ def __init__(self): @@ -51,7 +51,7 @@ def plain_text_search( Imitates an LLM's generate function by streaming text to a queue. - Not needed once we integrate with LEAP. + Not needed once we integrate with the lemonade API. """ # Turn the question into key words diff --git a/img/favicon.ico b/img/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..6a396a60e5894fb2c05ff04351eb237c7317b97f GIT binary patch literal 126745 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 Cy`Mw? literal 0 HcmV?d00001 diff --git a/installer/AMD_LICENSE b/installer/AMD_LICENSE new file mode 100644 index 00000000..792abf1f --- /dev/null +++ b/installer/AMD_LICENSE @@ -0,0 +1,99 @@ +AMD Software End User License Agreement + +IMPORTANT-READ CAREFULLY: DO NOT INSTALL, COPY OR USE THE ENCLOSED SOFTWARE, DOCUMENTATION (AS DEFINED BELOW), OR ANY PORTION THEREOF, UNTIL YOU HAVE CAREFULLY READ AND AGREED TO THE FOLLOWING TERMS AND CONDITIONS. THIS IS A LEGAL AGREEMENT ("AGREEMENT") BETWEEN YOU (EITHER AN INDIVIDUAL OR AN ENTITY) ("YOU") AND ADVANCED MICRO DEVICES, INC. ("AMD"). + +IF YOU DO NOT AGREE TO THE TERMS OF THIS AGREEMENT, DO NOT INSTALL, COPY OR USE THIS SOFTWARE. BY INSTALLING, COPYING OR USING THE SOFTWARE YOU AGREE TO ALL THE TERMS AND CONDITIONS OF THIS AGREEMENT. + +1. DEFINITIONS + +"Derivative Works" means any work, revision, modification or adaptation made to or derived from the Software, or any work that incorporates the Software, in whole or in part. + +"Documentation" means install scripts and online or electronic documentation associated, included, or provided in connection with the Software, or any portion thereof. + +"Free Software License" means an open source or other license that requires, as a condition of use, modification or distribution, that any resulting software must be (a) disclosed or distributed in source code form; (b) licensed for the purpose of making derivative works; or (c) redistributable at no charge. + + "Intellectual Property Rights" means all copyrights, trademarks, trade secrets, patents, mask works, and all related, similar, or other intellectual property rights recognized in any jurisdiction worldwide, including all applications and registrations with respect thereto. + + "Object Code" means machine readable computer programming code files, which is not in a human readable form. + +"Software" means the enclosed AMD software program or any portion thereof that is provided to You. + +"Source Code" means computer programming code in human readable form and related system level documentation, including all comments, symbols and any procedural code such as job control language. + +2. LICENSE + +Subject to the terms and conditions of this Agreement, AMD hereby grants You a non-exclusive, royalty-free, revocable, non-transferable, limited, copyright license to + +install and use the Software solely in Object Code form in conjunction with systems or components that include or incorporate AMD products, as applicable; + +create Derivative Works solely in Object Code form of the Software for use with systems or components that include or incorporate AMD products, as applicable; + +unless otherwise prohibited by a confidentiality agreement, make and distribute copies of the Derivative Works to Your partners and customers for use in conjunction with systems or components that include or incorporate AMD products, provided that such distribution shall be under a license agreement with terms and conditions at least as restrictive as those set forth in the Agreement; and + +use and reference the Documentation, if any, solely in connection with the Software and Derivative Works. + +3. RESTRICTIONS + +Except for the limited license expressly granted in Section 2 herein, You have no other rights in the Software, whether express, implied, arising by estoppel or otherwise. Further restrictions regarding Your use of the Software are set forth below. Except for the limited license expressly granted in Section 2, You may not: + +modify or create derivative works of the Software or Documentation; + +distribute, publish, display, sublicense, assign or otherwise transfer the Software or Documentation; + +decompile, reverse engineer, disassemble or otherwise reduce the Software to Source Code form (except as allowed by applicable law); + +alter or remove any copyright, trademark or patent notice(s) in the Software or Documentation; or + +use the Software and Documentation to: (i) develop inventions directly derived from Confidential Information to seek patent protection; (ii) assist in the analysis of Your patents and patent applications; or (iii) modify existing patents; or + +use, modify and/or distribute any of the Software or Documentation so that any part becomes subject to a Free Software License. + +4. FEEDBACK + +You have no obligation to give AMD any suggestions, comments or other feedback ("Feedback") relating to the Software or Documentation. However, AMD may use and include any Feedback that it receives from You to improve the Software, Documentation, or other AMD products, software, and technologies. Accordingly, for any Feedback You provide to AMD, You grant AMD and its affiliates and subsidiaries a worldwide, non-exclusive, irrevocable, royalty-free, perpetual license to, directly or indirectly, use, reproduce, license, sublicense, distribute, make, have made, sell and otherwise commercialize the Feedback in the Software, Documentation, or other AMD products, software and technologies. You further agree not to provide any Feedback that (a) You know is subject to any Intellectual Property Rights of any third party or (b) is subject to license terms which seek to require any products incorporating or derived from such Feedback, or other AMD intellectual property, to be licensed to or otherwise shared with any third party. + +5. OWNERSHIP AND COPYRIGHT OF SOFTWARE + +The Software, including all Intellectual Property Rights therein, and the Documentation are and remain the sole and exclusive property of AMD or its licensors, and You shall have no right, title or interest therein except as expressly set forth in this Agreement. + +6. WARRANTY DISCLAIMER + +THE SOFTWARE AND DOCUMENTATION ARE PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND. AMD DISCLAIMS ALL WARRANTIES, EXPRESS, IMPLIED, OR STATUTORY, INCLUDING BUT NOT LIMITED TO THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, THAT THE SOFTWARE OR DOCUMENTATION WILL RUN UNINTERRUPTED OR ERROR-FREE OR WARRANTIES ARISING FROM CUSTOM OF TRADE OR COURSE OF USAGE. THE ENTIRE RISK ASSOCIATED WITH THE USE OF THE SOFTWARE AND DOCUMENTATION IS ASSUMED BY YOU. Some jurisdictions do not allow the exclusion of implied warranties, so the above exclusion may not apply to You. + +7. LIMITATION OF LIABILITY AND INDEMNIFICATION + +AMD AND ITS LICENSORS WILL NOT, UNDER ANY CIRCUMSTANCES BE LIABLE TO YOU FOR ANY PUNITIVE, DIRECT, INCIDENTAL, INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES ARISING FROM USE OF THE SOFTWARE, DOCUMENTATION, OR THIS AGREEMENT EVEN IF AMD AND ITS LICENSORS HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. In no event shall AMD's total liability to You for all damages, losses, and causes of action (whether in contract, tort (including negligence) or otherwise) exceed the amount of $100 USD. You agree to defend, indemnify and hold harmless AMD and its licensors, and any of their directors, officers, employees, affiliates or agents from and against any and all loss, damage, liability and other expenses (including reasonable attorneys' fees), resulting from Your use of the Software, Documentation, or violation of the terms and conditions of this Agreement. + +8. EXPORT RESTRICTIONS + +You shall adhere to all applicable U.S. import/export laws and regulations, as well as the import/export control laws and regulations of other countries as applicable. You further agree to not export, re-export, or transfer, directly or indirectly, any product, technical data, software or source code received from AMD under this license, or the direct product of such technical data or software to any country for which the United States or any other applicable government requires an export license or other governmental approval without first obtaining such licenses or approvals; or in violation of any applicable laws or regulations of the United States or the country where the technical data or software was obtained. You acknowledge the technical data and software received will not, in the absence of authorization from U.S. or local law and regulations as applicable, be used by or exported, re-exported or transferred to: (i) any sanctioned or embargoed country, or to nationals or residents of such countries; (ii) any restricted end-user as identified on any applicable government end-user list; or (iii) any party where the end-use involves nuclear, chemical/biological weapons, rocket systems, or unmanned air vehicles. For the most current Country Group listings, or for additional information about the EAR or Your obligations under those regulations, please refer to the U.S. Bureau of Industry and Security’s website at http://www.bis.doc.gov/. + +9. NOTICE TO U.S. GOVERNMENT END USERS + +The Software and Documentation are "commercial items", as that term is defined at 48 C.F.R. Section 2.101, consisting of "commercial computer software" and "commercial computer software documentation", as such terms are used in 48 C.F.R. Section 12.212 and 48 C.F.R. Section 227.7202, respectively. Consistent with 48 C.F.R. Section 12.212 or 48 C.F.R. Section 227.7202-1 through 227.7202-4, as applicable, the commercial computer software and commercial computer software documentation are being licensed to U.S. Government end users (a) only as commercial items and (b) with only those rights as are granted to all other end users pursuant to the terms and conditions set forth in this Agreement. Unpublished rights are reserved under the copyright laws of the United States. + +10. TERMINATION OF LICENSE + +This Agreement will terminate immediately without notice from AMD or judicial resolution if (1) You fail to comply with any provisions of this Agreement, or (2) You provide AMD with notice that You would like to terminate this Agreement. Upon termination of this Agreement, You must delete or destroy all copies of the Software. Upon termination or expiration of this Agreement, all provisions survive except for Section 2. + +11. SUPPORT AND UPDATES + +AMD is under no obligation to provide any kind of support under this Agreement. AMD may, in its sole discretion, provide You with updates to the Software and Documentation, and such updates will be covered under this Agreement. + +12. GOVERNING LAW + +This Agreement is made under and shall be construed according to the laws of the State of California, excluding conflicts of law rules. Each party submits to the jurisdiction of the state and federal courts of Santa Clara County and the Northern District of California for the purposes of this Agreement. You acknowledge that Your breach of this Agreement may cause irreparable damage and agree that AMD shall be entitled to seek injunctive relief under this Agreement, as well as such further relief as may be granted by a court of competent jurisdiction. + +13. PRIVACY + +We may be required under applicable data protection law to provide you with certain information about who we are, how we process your personal data and for what purposes and your rights in relation to your personal information and how to exercise them. This information is provided in www.amd.com/en/corporate/privacy. It is important that you read that information. AMD’s Cookie Policy, sets out information about the cookies AMD uses. + +14. GENERAL PROVISIONS + +You may not assign this Agreement without the prior written consent of AMD and any assignment without such consent will be null and void. The parties do not intend that any agency or partnership relationship be created between them by this Agreement. Each provision of this Agreement shall be interpreted in such a manner as to be effective and valid under applicable law. However, in the event that any provision of this Agreement becomes or is declared unenforceable by any court of competent jurisdiction, such provision shall be deemed deleted and the remainder of this Agreement shall remain in full force and effect. + +15. ENTIRE AGREEMENT + +This Agreement sets forth the entire agreement and understanding between the parties with respect to the Software and supersedes and merges all prior oral and written agreements, discussions and understandings between them regarding the subject matter of this Agreement. No waiver or modification of any provision of this Agreement shall be binding unless made in writing and signed by an authorized representative of each party. + + \ No newline at end of file diff --git a/installer/Installer.nsi b/installer/Installer.nsi new file mode 100644 index 00000000..7843d0af --- /dev/null +++ b/installer/Installer.nsi @@ -0,0 +1,333 @@ +; Lemonade Server Installer Script + +!define /ifndef NPU_DRIVER_VERSION "32.0.203.237" + +; Define main variables +Name "Lemonade Server" +OutFile "Lemonade_Server_Installer.exe" + +; Include modern UI elements +!include "MUI2.nsh" + +!include FileFunc.nsh + +; Include LogicLib for logging in silent mode +!include LogicLib.nsh +Var LogHandle + +Var LEMONADE_SERVER_STRING +Var LEMONADE_CONDA_ENV +Var HYBRID_SELECTED +Var HYBRID_CLI_OPTION + +; Define a section for the installation +Section "Install Main Components" SEC01 +SectionIn RO ; Read only, always installed + DetailPrint "------------------------" + DetailPrint "- Installation Section -" + DetailPrint "------------------------" + + ; Once we're done downloading and installing the pip packages the size comes out to about 2GB + AddSize 2097152 + + ; Check if directory exists before proceeding + IfFileExists "$INSTDIR\*.*" 0 continue_install + ${IfNot} ${Silent} + MessageBox MB_YESNO "An existing $LEMONADE_SERVER_STRING installation was found at $INSTDIR.$\n$\nWould you like to remove it and continue with the installation?" IDYES remove_dir + ; If user selects No, show exit message and quit the installer + MessageBox MB_OK "Installation cancelled. Exiting installer..." + Quit + ${Else} + Goto remove_dir + ${EndIf} + + remove_dir: + ; Try to remove directory and verify it was successful + + ; Attempt conda remove of the env, to help speed things up + ExecWait 'conda env remove -yp "$INSTDIR\$LEMONADE_CONDA_ENV"' + + ; Delete all remaining files + RMDir /r "$INSTDIR" + + IfFileExists "$INSTDIR\*.*" 0 continue_install + ${IfNot} ${Silent} + MessageBox MB_OK "Unable to remove existing installation. Please close any applications using $LEMONADE_SERVER_STRING and try again." + ${EndIf} + Quit + + continue_install: + ; Create fresh directory + CreateDirectory "$INSTDIR" + DetailPrint "*** INSTALLATION STARTED ***" + + ; Attach console to installation to enable logging + System::Call 'kernel32::GetStdHandle(i -11)i.r0' + StrCpy $LogHandle $0 ; Save the handle to LogHandle variable + System::Call 'kernel32::AttachConsole(i -1)i.r1' + ${If} $LogHandle = 0 + ${OrIf} $1 = 0 + System::Call 'kernel32::AllocConsole()' + System::Call 'kernel32::GetStdHandle(i -11)i.r0' + StrCpy $LogHandle $0 ; Update the LogHandle variable if the console was allocated + ${EndIf} + DetailPrint "- Initialized logging" + + ; Set the output path for future operations + SetOutPath "$INSTDIR" + + DetailPrint "Starting '$LEMONADE_SERVER_STRING' Installation..." + DetailPrint 'Configuration:' + DetailPrint ' Install Dir: $INSTDIR' + DetailPrint ' Minimum NPU Driver Version: ${NPU_DRIVER_VERSION}' + DetailPrint '-------------------------------------------' + + # Pack turnkeyml repo into the installer + # Exclude hidden files (like .git, .gitignore) and the installation folder itself + File /r /x nsis.exe /x installer /x .* /x *.pyc /x docs /x examples /x utilities ..\*.* + + DetailPrint "- Packaged repo" + + ; Check if conda is available + ExecWait 'where conda' $2 + DetailPrint "- Checked if conda is available" + + ; If conda is not found, show a message + ; Otherwise, continue with the installation + StrCmp $2 "0" create_env conda_not_available + + conda_not_available: + DetailPrint "- Conda not installed." + ${IfNot} ${Silent} + MessageBox MB_YESNO "Conda is not installed. Would you like to install Miniconda?" IDYES install_miniconda IDNO exit_installer + ${Else} + Goto install_miniconda + ${EndIf} + + exit_installer: + DetailPrint "- Something went wrong. Exiting installer" + Quit + + install_miniconda: + DetailPrint "-------------" + DetailPrint "- Miniconda -" + DetailPrint "-------------" + DetailPrint "- Downloading Miniconda installer..." + ExecWait 'curl -s -o "$TEMP\Miniconda3-latest-Windows-x86_64.exe" "https://repo.anaconda.com/miniconda/Miniconda3-latest-Windows-x86_64.exe"' + + ; Install Miniconda silently + ExecWait '"$TEMP\Miniconda3-latest-Windows-x86_64.exe" /InstallationType=JustMe /AddToPath=1 /RegisterPython=0 /S /D=$PROFILE\miniconda3' $2 + ; Check if Miniconda installation was successful + ${If} $2 == 0 + DetailPrint "- Miniconda installation successful" + ${IfNot} ${Silent} + MessageBox MB_OK "Miniconda has been successfully installed." + ${EndIf} + + StrCpy $R1 "$PROFILE\miniconda3\Scripts\conda.exe" + Goto create_env + + ${Else} + DetailPrint "- Miniconda installation failed" + ${IfNot} ${Silent} + MessageBox MB_OK "Error: Miniconda installation failed. Installation will be aborted." + ${EndIf} + Goto exit_installer + ${EndIf} + + create_env: + DetailPrint "---------------------" + DetailPrint "- Conda Environment -" + DetailPrint "---------------------" + + DetailPrint "- Initializing conda..." + ; Use the appropriate conda executable + ${If} $R1 == "" + StrCpy $R1 "conda" + ${EndIf} + ; Initialize conda (needed for systems where conda was previously installed but not initialized) + nsExec::ExecToStack '"$R1" init' + + DetailPrint "- Creating a Python 3.10 environment named '$LEMONADE_CONDA_ENV' in the installation directory: $INSTDIR..." + ExecWait '"$R1" create -p "$INSTDIR\$LEMONADE_CONDA_ENV" python=3.10 -y' $R0 + + ; Check if the environment creation was successful (exit code should be 0) + StrCmp $R0 0 install_lemonade env_creation_failed + + env_creation_failed: + DetailPrint "- ERROR: Environment creation failed" + ; Display an error message and exit + ${IfNot} ${Silent} + MessageBox MB_OK "ERROR: Failed to create the Python environment. Installation will be aborted." + ${EndIf} + Quit + + install_lemonade: + DetailPrint "-------------------------" + DetailPrint "- Lemonade Installation -" + DetailPrint "-------------------------" + + + DetailPrint "- Installing $LEMONADE_SERVER_STRING..." + ${If} $HYBRID_SELECTED == "true" + nsExec::ExecToLog '"$INSTDIR\$LEMONADE_CONDA_ENV\python.exe" -m pip install -e "$INSTDIR"[llm-oga-hybrid] --no-warn-script-location' + ${Else} + nsExec::ExecToLog '"$INSTDIR\$LEMONADE_CONDA_ENV\python.exe" -m pip install -e "$INSTDIR"[llm] --no-warn-script-location' + ${EndIf} + Pop $R0 ; Return value + DetailPrint "- $LEMONADE_SERVER_STRING install return code: $R0" + + ; Check if installation was successful (exit code should be 0) + StrCmp $R0 0 install_success install_failed + + install_success: + DetailPrint "- $LEMONADE_SERVER_STRING installation successful" + + DetailPrint "*** INSTALLATION COMPLETED ***" + # Create a shortcut inside $INSTDIR + CreateShortcut "$INSTDIR\lemonade-server.lnk" "$SYSDIR\cmd.exe" "/C conda run --no-capture-output -p $INSTDIR\$LEMONADE_CONDA_ENV lemonade serve" "$INSTDIR\img\favicon.ico" + + Goto end + + install_failed: + DetailPrint "- $LEMONADE_SERVER_STRING installation failed" + ${IfNot} ${Silent} + MessageBox MB_OK "ERROR: $LEMONADE_SERVER_STRING package failed to install using pip. Installation will be aborted." + ${EndIf} + Quit + + end: +SectionEnd + +Section "Install Ryzen AI Hybrid Execution" HybridSec + DetailPrint "------------------------" + DetailPrint "- Ryzen AI Section -" + DetailPrint "------------------------" + + ; Once we're done downloading and installing the archive the size comes out to about 1GB + AddSize 1048576 + + nsExec::ExecToLog 'conda run --no-capture-output -p $INSTDIR\$LEMONADE_CONDA_ENV lemonade-install --ryzenai hybrid -y' + + Pop $R0 ; Return value + DetailPrint "Hybrid execution mode install return code: $R0" + + ; Check if installation was successful (exit code should be 0) + StrCmp $R0 0 end install_failed + + install_failed: + DetailPrint "- Hybrid installation failed" + ${IfNot} ${Silent} + MessageBox MB_OK "ERROR: Hybrid mode failed to install using pip. Installation will be aborted." + ${EndIf} + Quit + + end: +SectionEnd + +Section "-Add Desktop Shortcut" ShortcutSec + # Create a desktop shortcut that points to the newly created shortcut in $INSTDIR + CreateShortcut "$DESKTOP\lemonade-server.lnk" "$INSTDIR\lemonade-server.lnk" +SectionEnd + +Function RunServer + ExecShell "open" "$INSTDIR\LEMONADE-SERVER.lnk" +FunctionEnd + +; Define constants for better readability +!define ICON_FILE "..\img\favicon.ico" + +; Finish Page settings +!define MUI_TEXT_FINISH_INFO_TITLE "$LEMONADE_SERVER_STRING installed successfully!" +!define MUI_TEXT_FINISH_INFO_TEXT "A shortcut has been added to your Desktop. What would you like to do next?" + +!define MUI_FINISHPAGE_RUN +!define MUI_FINISHPAGE_RUN_FUNCTION RunServer +!define MUI_FINISHPAGE_RUN_NOTCHECKED +!define MUI_FINISHPAGE_RUN_TEXT "Run Lemonade Server" + +Function .onSelChange + StrCpy $HYBRID_SELECTED "false" + SectionGetFlags ${HybridSec} $0 + IntOp $0 $0 & ${SF_SELECTED} + StrCmp $0 ${SF_SELECTED} 0 +2 + StrCpy $HYBRID_SELECTED "true" + ;MessageBox MB_OK "Component 2 is selected" +FunctionEnd + +Function SkipLicense + ${IfNot} ${SectionIsSelected} ${HybridSec} + abort ;skip AMD license if hybrid was not enabled + ${EndIf} +FunctionEnd + + +; MUI Settings +!insertmacro MUI_PAGE_WELCOME +!insertmacro MUI_PAGE_COMPONENTS + +!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipLicense +!insertmacro MUI_PAGE_LICENSE "AMD_LICENSE" + +!insertmacro MUI_PAGE_DIRECTORY +!insertmacro MUI_PAGE_INSTFILES +!insertmacro MUI_PAGE_FINISH +!insertmacro MUI_LANGUAGE "English" + +!define MUI_PAGE_CUSTOMFUNCTION_SHOW .onSelChange + + + + +; Set the installer icon +Icon ${ICON_FILE} + +; Language settings +LangString MUI_TEXT_WELCOME_INFO_TITLE "${LANG_ENGLISH}" "Welcome to the $LEMONADE_SERVER_STRING Installer" +LangString MUI_TEXT_WELCOME_INFO_TEXT "${LANG_ENGLISH}" "This wizard will install $LEMONADE_SERVER_STRING on your computer." +LangString MUI_TEXT_DIRECTORY_TITLE "${LANG_ENGLISH}" "Select Installation Directory" +LangString MUI_TEXT_INSTALLING_TITLE "${LANG_ENGLISH}" "Installing $LEMONADE_SERVER_STRING" +LangString MUI_TEXT_FINISH_TITLE "${LANG_ENGLISH}" "Installation Complete" +LangString MUI_TEXT_FINISH_SUBTITLE "${LANG_ENGLISH}" "Thank you for installing $LEMONADE_SERVER_STRING!" +LangString MUI_TEXT_ABORT_TITLE "${LANG_ENGLISH}" "Installation Aborted" +LangString MUI_TEXT_ABORT_SUBTITLE "${LANG_ENGLISH}" "Installation has been aborted." +LangString MUI_BUTTONTEXT_FINISH "${LANG_ENGLISH}" "Finish" +LangString MUI_TEXT_LICENSE_TITLE ${LANG_ENGLISH} "AMD License Agreement" +LangString MUI_TEXT_LICENSE_SUBTITLE ${LANG_ENGLISH} "Please review the license terms before installing AMD Ryzen AI Hybrid Execution Mode." +LangString DESC_SEC01 ${LANG_ENGLISH} "The minimum set of dependencies for a lemonade server that runs LLMs on CPU." +LangString DESC_HybridSec ${LANG_ENGLISH} "Add support for running LLMs on Ryzen AI hybrid execution mode, which uses both the NPU and iGPU for improved performance on Ryzen AI 300-series processors." + +; Insert the description macros +!insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN + !insertmacro MUI_DESCRIPTION_TEXT ${SEC01} $(DESC_SEC01) + !insertmacro MUI_DESCRIPTION_TEXT ${HybridSec} $(DESC_HybridSec) +!insertmacro MUI_FUNCTION_DESCRIPTION_END + +Function .onInit + StrCpy $LEMONADE_SERVER_STRING "Lemonade Server" + StrCpy $LEMONADE_CONDA_ENV "lemon_env" + StrCpy $HYBRID_SELECTED "true" + + ; Set the install directory, allowing /D override from CLI install + ${If} $InstDir != "" + ; /D was used + ${Else} + ; Use the default + StrCpy $InstDir "$LOCALAPPDATA\lemonade_server" + ${EndIf} + + ; Disable hybrid mode by default in silent mode + ; Use /Extras="hybrid" option to enable it + ${If} ${Silent} + + ${GetParameters} $CMDLINE + ${GetOptions} $CMDLINE "/Extras=" $HYBRID_CLI_OPTION + + ${IfNot} $HYBRID_CLI_OPTION == "hybrid" + SectionSetFlags ${HybridSec} 0 + StrCpy $HYBRID_SELECTED "false" + ${EndIf} + ${EndIf} + + +FunctionEnd \ No newline at end of file diff --git a/setup.py b/setup.py index 83705141..72c9e637 100644 --- a/setup.py +++ b/setup.py @@ -69,6 +69,7 @@ "human-eval-windows==1.0.4", "fastapi", "uvicorn[standard]", + "openai", ], "llm-oga-cpu": [ "onnxruntime-genai>=0.5.2", @@ -77,13 +78,13 @@ "turnkeyml[llm]", ], "llm-oga-igpu": [ - "onnxruntime-genai-directml==0.4.0", + "onnxruntime-genai-directml>=0.4.0", "torch>=2.0.0,<2.4", "transformers<4.45.0", "turnkeyml[llm]", ], "llm-oga-cuda": [ - "onnxruntime-genai-cuda==0.4.0", + "onnxruntime-genai-cuda>=0.4.0", "torch>=2.0.0,<2.4", "transformers<4.45.0", "turnkeyml[llm]", diff --git a/src/lemonade/cache.py b/src/lemonade/cache.py index dcd6233c..c1416568 100644 --- a/src/lemonade/cache.py +++ b/src/lemonade/cache.py @@ -17,25 +17,33 @@ def checkpoint_to_model_name(checkpoint_name: str) -> str: return checkpoint_name.split("/")[1] +def get_timestamp() -> str: + """ + Get a timestamp string in the format: + y_m_d_h_m_s + """ + # Get the current time in GMT + current_time = datetime.now(timezone.utc) + + # Format the timestamp string + timestamp = current_time.strftime("%Yy_%mm_%dd_%Hh_%Mm_%Ss") + return timestamp + + def build_name(input_name): """ Name the lemonade build by concatenating these two factors: 1. Sanitize the input name (typically a model checkpoint name) by replacing any `/` characters with `_`. - 2. Timestamp in the format: - m_d_y_h_m_s - This timestamp ensures that builds in the same cache will not + 2. Timestamp to ensure that builds in the same cache will not collide in the same build directory. """ # Sanitize the input name input_name_sanitized = input_name.replace("/", "_") - # Get the current time in GMT - current_time = datetime.now(timezone.utc) - - # Format the timestamp string - timestamp = current_time.strftime("%Yy_%mm_%dd_%Hh_%Mm_%Ss") + # Get the formatted timestamp string + timestamp = get_timestamp() return f"{input_name_sanitized}_{timestamp}" @@ -62,3 +70,4 @@ class Keys: OGA_MODELS_SUBFOLDER = "oga_models_subfolder" MEMORY_USAGE_PLOT = "memory_usage_plot" MAX_MEMORY_USED_GB = "max_memory_used_GB" + MAX_MEMORY_USED_GBYTE = "max_memory_used_gbyte" diff --git a/src/lemonade/cli.py b/src/lemonade/cli.py index c102709f..f90cb8a4 100644 --- a/src/lemonade/cli.py +++ b/src/lemonade/cli.py @@ -21,10 +21,10 @@ from lemonade.tools.mmlu import AccuracyMMLU from lemonade.tools.humaneval import AccuracyHumaneval from lemonade.tools.perplexity import AccuracyPerplexity -from lemonade.tools.chat import LLMPrompt, Serve -from lemonade.tools.serve import ServerPreview +from lemonade.tools.prompt import LLMPrompt from lemonade.tools.quark.quark_load import QuarkLoad from lemonade.tools.quark.quark_quantize import QuarkQuantize +from lemonade.tools.serve import Server def main(): @@ -39,7 +39,6 @@ def main(): AccuracyPerplexity, LLMPrompt, AdaptHuggingface, - Serve, HuggingfaceBench, OgaBench, QuarkQuantize, @@ -49,7 +48,7 @@ def main(): Cache, Version, SystemInfo, - ServerPreview, + Server, ] # Import onnxruntime-genai recipes diff --git a/src/lemonade/tools/bench.py b/src/lemonade/tools/bench.py new file mode 100644 index 00000000..e720692e --- /dev/null +++ b/src/lemonade/tools/bench.py @@ -0,0 +1,275 @@ +from abc import ABC, abstractmethod +import argparse +import os +import platform +import psutil +from turnkeyml.state import State +from turnkeyml.tools import Tool +from lemonade.cache import Keys + +default_iterations = 10 +default_warmup_runs = 5 +default_prompt_length = 64 +default_output_tokens = 32 +default_prompt = "Hello, I am conscious and" + + +class Bench(Tool, ABC): + """ + Abstract parent class for tools that benchmark the performance of the generate() + method of an LLM. + """ + + def __init__(self, monitor_message="Benchmarking LLM"): + super().__init__(monitor_message) + + # The minimum set of statistics that a benchmark tool will produce + # Inherited tools should append any additional statistics they generate to this list + self.status_stats = [ + Keys.PROMPT_TOKENS, + Keys.SECONDS_TO_FIRST_TOKEN, + Keys.STD_DEV_SECONDS_TO_FIRST_TOKEN, + Keys.PREFILL_TOKENS_PER_SECOND, + Keys.TOKEN_GENERATION_TOKENS_PER_SECOND, + Keys.MAX_MEMORY_USED_GBYTE, + ] + + # Minimum per measurement statistics + # Inherited tools should add additional lists for other per prompt statistics + self.input_ids_len_list = [] + self.mean_time_to_first_token_list = [] + self.std_dev_time_to_first_token_list = [] + self.prefill_tokens_per_second_list = [] + self.token_generation_tokens_per_second_list = [] + self.max_memory_used_gb_list = [] + + # Max memory used can only be measured on Windows systems + self.save_max_memory_used = platform.system() == "Windows" + + # This is set to True only for the duration of the first call to run_prompt + self.first_run_prompt = None + + @staticmethod + def parser(parser: argparse.ArgumentParser = None, add_help: bool = True): + # Allow inherited classes to initialize and pass in a parser, add parameters to it if so + if parser is None: + parser = __class__.helpful_parser( + short_description="Benchmark an LLM", add_help=add_help + ) + + parser.add_argument( + "--iterations", + "-i", + required=False, + type=int, + default=default_iterations, + help="Number of benchmarking iterations to run (default: " + f"{default_iterations})", + ) + + parser.add_argument( + "--warmup-iterations", + "-w", + required=False, + type=int, + default=default_warmup_runs, + help="Number of benchmarking iterations to use for cache warmup " + "(the results of these iterations " + f"are not included in the results; default: {default_warmup_runs})", + ) + + parser.add_argument( + "--prompts", + "-p", + nargs="+", + required=False, + default=[str(default_prompt_length)], + metavar="PROMPT", + help="Input one or more prompts to the LLM. Three formats are supported. " + "1) integer: use a synthetic prompt with the specified length " + "2) str: use a user-provided prompt string " + "3) path/to/prompt.txt: load the prompt from a text file. " + f"(default: {default_prompt_length}) ", + ) + + parser.add_argument( + "--output-tokens", + required=False, + type=int, + default=default_output_tokens, + help="Number of new tokens the LLM should make (default: " + f"{default_output_tokens})", + ) + + return parser + + def get_prompt_str(self, _state, token_length): + """ + Returns a string with approximately the prescribed token length. + Note: Actual token length is dependent on the tokenizer. + """ + return "word " * (token_length - 1) + + def parse(self, state: State, args, known_only=True) -> argparse.Namespace: + """ + Helper function to parse CLI arguments into the args expected by run() + """ + + parsed_args = super().parse(state, args, known_only) + + if parsed_args.prompts is None: + parsed_args.prompts = [str(default_prompt_length)] + + # Decode prompt arg into a list of prompt strings + prompt_strings = [] + for prompt_item in parsed_args.prompts: + if prompt_item.isdigit(): + # Generate a prompt with the requested length + token_length = int(prompt_item) + prompt_strings.append(self.get_prompt_str(state, token_length)) + + elif os.path.exists(prompt_item): + with open(prompt_item, "r", encoding="utf-8") as f: + prompt_strings.append(f.read()) + + else: + # No change to the prompt + prompt_strings.append(prompt_item) + parsed_args.prompts = prompt_strings + + return parsed_args + + def run( + self, + state: State, + prompts: list[str] = None, + iterations: int = default_iterations, + warmup_iterations: int = default_warmup_runs, + output_tokens: int = default_output_tokens, + **kwargs, + ) -> State: + """ + Args: + - prompts: List of input prompts used as starting points for LLM text generation + - iterations: number of benchmarking samples to take; results are + reported as the median and mean of the samples. + - warmup_iterations: subset of the iterations to treat as warmup, + and not included in the results. + - output_tokens: Number of new tokens LLM to create. + - kwargs: Additional parameters used by bench tools + """ + + if prompts is None: + prompts = ["word " * (default_prompt_length - 2)] + elif isinstance(prompts, str): + prompts = [prompts] + + state.save_stat("prompts", prompts) + state.save_stat("iterations", iterations) + state.save_stat("warmup_iterations", warmup_iterations) + state.save_stat("output_tokens", output_tokens) + + counter = 0 + report_progress_fn = lambda x: self.set_percent_progress( + 100 * (counter + x) / len(prompts) + ) + self.first_run_prompt = True + for counter, prompt in enumerate(prompts): + report_progress_fn(0) + + self.run_prompt( + state, + report_progress_fn, + prompt, + iterations, + warmup_iterations, + output_tokens, + **kwargs, + ) + self.first_run_prompt = False + + if self.save_max_memory_used: + self.max_memory_used_gb_list.append( + psutil.Process().memory_info().peak_wset / 1024**3 + ) + + self.set_percent_progress(None) + self.save_stats(state) + + return state + + @abstractmethod + def run_prompt( + self, + state, + report_progress_fn, + prompt, + iterations, + warmup_iterations, + output_tokens, + **kwargs, + ): + pass + + @staticmethod + def get_item_or_list(lst): + """ + If the list is just a single item then return the item, else return the list + """ + if len(lst) == 1: + return lst[0] + else: + return lst + + def save_stats(self, state): + # Save performance data to stats + state.save_stat( + Keys.PROMPT_TOKENS, self.get_item_or_list(self.input_ids_len_list) + ) + state.save_stat( + Keys.SECONDS_TO_FIRST_TOKEN, + self.get_item_or_list(self.mean_time_to_first_token_list), + ) + if not all( + element is None for element in self.std_dev_time_to_first_token_list + ): + state.save_stat( + Keys.STD_DEV_SECONDS_TO_FIRST_TOKEN, + self.get_item_or_list(self.std_dev_time_to_first_token_list), + ) + state.save_stat( + Keys.PREFILL_TOKENS_PER_SECOND, + self.get_item_or_list(self.prefill_tokens_per_second_list), + ) + state.save_stat( + Keys.TOKEN_GENERATION_TOKENS_PER_SECOND, + self.get_item_or_list(self.token_generation_tokens_per_second_list), + ) + if self.save_max_memory_used: + state.save_stat( + Keys.MAX_MEMORY_USED_GBYTE, + self.get_item_or_list(self.max_memory_used_gb_list), + ) + + @staticmethod + def not_enough_tokens(output_tokens: int): + """ + Raise an exception that explains why a benchmark did not produce any results + """ + + raise ValueError( + "Your model was benchmarked, however none of the benchmarking " + "iterations produced the requested amount of output tokens " + f"(currently {output_tokens}), so " + "the results have been discarded. You have the following options " + "to solve this: \n" + "1. Use the -p option to change the prompt to something that will " + "produce more output tokens. For example, 'The extremely long " + "story of my life, told in excruciating details is:' " + "is an example of a prompt that will result in a lot of output. \n" + "2. Set a lower value for --output-tokens to make it more likely " + "that the model will produce enough. \n" + "3. Set more verbose hyperparameters. \n" + "4. Run more benchmarking iterations, to improve the chance of " + "getting at least one with enough output tokens. \n" + ) diff --git a/src/lemonade/tools/huggingface_bench.py b/src/lemonade/tools/huggingface_bench.py index 1add8b3a..4eaa67f7 100644 --- a/src/lemonade/tools/huggingface_bench.py +++ b/src/lemonade/tools/huggingface_bench.py @@ -1,15 +1,16 @@ import argparse -import os from typing import List, Tuple import time import statistics +from statistics import StatisticsError from contextlib import nullcontext import torch import tqdm from turnkeyml.state import State -from turnkeyml.tools import Tool from lemonade.cache import Keys -import lemonade.tools.ort_genai.oga_bench as general +from lemonade.tools.bench import Bench + +default_beams = 1 def benchmark_huggingface_llm( @@ -21,6 +22,7 @@ def benchmark_huggingface_llm( target_output_tokens: int, iterations: int, warmup_iterations: int, + report_progress_fn, ) -> List[Tuple[float, int]]: # Inform the user whether the current execution is to measure @@ -44,7 +46,7 @@ def benchmark_huggingface_llm( with torch.no_grad(), torch.inference_mode(): # Don't capture time for warmup - for _ in tqdm.tqdm(range(warmup_iterations), desc=f"{mode} warmup"): + for count in tqdm.tqdm(range(warmup_iterations), desc=f"{mode} warmup"): model.generate( input_ids, num_beams=num_beams, @@ -53,8 +55,9 @@ def benchmark_huggingface_llm( early_stopping=early_stopping, pad_token_id=tokenizer.eos_token_id, ) + report_progress_fn((count + 1) / (warmup_iterations + iterations)) - for _ in tqdm.tqdm(range(iterations), desc=f"{mode} iterations"): + for count in tqdm.tqdm(range(iterations), desc=f"{mode} iterations"): # CUDA synchronization is required prior to GPU benchmarking # This has no negative effect on CPU-only benchmarks, and is more robust than # checking `model.device == "cuda"` since it applies to multi-GPU environments @@ -81,17 +84,21 @@ def benchmark_huggingface_llm( token_len = outputs.shape[1] - input_ids.shape[1] - # Only count an iteration it produced enough tokens + # Only count an iteration if it produced enough tokens if token_len >= target_output_tokens: per_iteration_result.append((latency, token_len)) + report_progress_fn( + (warmup_iterations + count + 1) / (warmup_iterations + iterations) + ) + if not per_iteration_result: - raise general.not_enough_tokens(target_output_tokens) + raise Bench.not_enough_tokens(target_output_tokens) return per_iteration_result -class HuggingfaceBench(Tool): +class HuggingfaceBench(Bench): """ Benchmarks the performance of the generate() method of an LLM loaded from Huggingface Transformers (or any object that supports a @@ -110,119 +117,56 @@ class HuggingfaceBench(Tool): unique_name = "huggingface-bench" - def __init__(self): - super().__init__(monitor_message="Benchmarking Huggingface LLM") - - self.status_stats = [ - Keys.SECONDS_TO_FIRST_TOKEN, - Keys.TOKEN_GENERATION_TOKENS_PER_SECOND, - ] - @staticmethod def parser(parser: argparse.ArgumentParser = None, add_help: bool = True): - # allow inherited classes to initialize and pass in a parser, add parameters to it if so + # Allow inherited classes to initialize and pass in a parser, add parameters to it if so if parser is None: parser = __class__.helpful_parser( - short_description="Benchmark a Huggingface-like LLM", add_help=add_help + short_description="Benchmark a torch.nn.Module LLM", + add_help=add_help, ) - parser.add_argument( - "--iterations", - "-i", - required=False, - type=int, - default=general.default_iterations, - help="Number of benchmarking iterations to run (default: " - f"{general.default_iterations})", - ) - - parser.add_argument( - "--warmup-iterations", - "-w", - required=False, - type=int, - default=general.default_warmup_runs, - help="Number of benchmarking iterations to use for cache warmup " - "(the results of these iterations " - f"are not included in the results; default: {general.default_warmup_runs})", - ) - - parser.add_argument( - "--prompt", - "-p", - required=False, - default=general.default_prompt, - help="Input prompt to the LLM. Three formats are supported. " - f"1) integer (default: {general.default_prompt}): " - "use a synthetic prompt with the specified length. " - "2) str: use a user-provided prompt string " - "3) path/to/prompt.txt: load the prompt from a text file.", - ) + parser = Bench.parser(parser) parser.add_argument( "--num-beams", required=False, type=int, - default=general.default_beams, - help=f"Number of beams for the LLM to use (default: {general.default_beams})", - ) - - parser.add_argument( - "--output-tokens", - required=False, - type=int, - default=general.default_output_tokens, - help="Number of new tokens the LLM should make (default: " - f"{general.default_output_tokens})", + default=default_beams, + help=f"Number of beams for the LLM to use (default: {default_beams})", ) return parser - def parse(self, state: State, args, known_only=True) -> argparse.Namespace: + def get_prompt_str(self, state, token_length): """ - Helper function to parse CLI arguments into the args expected - by run() + Returns a string with the prescribed token length. """ + model = state.model + tokenizer = state.tokenizer + test_prompt = "word " * (token_length - 2) + input_ids = ( + tokenizer(test_prompt, return_tensors="pt") + .to(device=model.device) + .input_ids + ) + test_token_length = input_ids.shape[1] + delta = test_token_length - token_length + if delta == 0: + return test_prompt + return "word " * max(token_length - 2 - delta, 0) - parsed_args = super().parse(state, args, known_only) - - # Decode prompt arg into a string prompt - if parsed_args.prompt.isdigit(): - # Generate a prompt with the requested length - length = int(parsed_args.prompt) - parsed_args.prompt = "word " * (length - 2) - - elif os.path.exists(parsed_args.prompt): - with open(parsed_args.prompt, "r", encoding="utf-8") as f: - parsed_args.prompt = f.read() - - else: - # No change to the prompt - pass - - return parsed_args - - def run( + def run_prompt( self, state: State, - prompt: str = general.default_prompt, - iterations: int = general.default_iterations, - warmup_iterations: int = general.default_warmup_runs, - num_beams: int = general.default_beams, - output_tokens: int = general.default_output_tokens, + report_progress_fn, + prompt: str, + iterations: int, + warmup_iterations: int, + output_tokens: int, + num_beams: int = default_beams, ) -> State: """ - Args: - - prompt: input prompt used as a starting point for LLM text generation - - iterations: number of benchmarking samples to take; results are - reported as the median and mean of the samples. - - warmup_iterations: subset of the iterations to treat as warmup, - and not included in the results. - - num_beams: number of beams to use in the LLM beam search. If the LLM - instance has hardcoded its number of beams already, this value - must match the hardcoded value. - - output_tokens: Number of new tokens LLM to create. - We don't have access to the internal timings of generate(), so time to first token (TTFT, aka prefill latency) and token/s are calculated using the following formulae: prefill_latency = latency of generate(output_tokens=1) @@ -230,27 +174,36 @@ def run( tokens_per_second = (new_tokens - 1) / (execution_latency - prefill_latency) """ - if vars(state).get(Keys.MODEL) is None: - raise ValueError( - f"{self.__class__.__name__} requires that a model be passed from another tool" - ) + if self.first_run_prompt: + if vars(state).get(Keys.MODEL) is None: + raise ValueError( + f"{self.__class__.__name__} requires that a model be passed from another tool" + ) + if ( + vars(state).get("num_beams") + and vars(state).get("num_beams") != num_beams + ): + raise ValueError( + f"Number of beams was set to {vars(state).get('num_beams')} " + f"in a previous tool, but it is set to {num_beams} in " + "this tool. The values must be the same." + ) - if vars(state).get("num_beams") and vars(state).get("num_beams") != num_beams: - raise ValueError( - f"Number of beams was set to {vars(state).get('num_beams')} " - f"in a previous tool, but it is set to {num_beams} in " - "this tool. The values must be the same." - ) + # Save benchmarking parameters + state.save_stat("num_beams", num_beams) model = state.model tokenizer = state.tokenizer dtype = state.dtype - # Generate the input_ids outside of the benchmarking function to make sure + # Generate the input_ids outside the benchmarking function to make sure # the same input_ids are used everywhere input_ids = ( tokenizer(prompt, return_tensors="pt").to(device=model.device).input_ids ) + self.input_ids_len_list.append(input_ids.shape[1]) + + prefill_report_progress_fn = lambda x: report_progress_fn(0.5 * x) # Benchmark prefill time (time to first token) prefill_per_iteration_result = benchmark_huggingface_llm( @@ -262,12 +215,26 @@ def run( target_output_tokens=1, iterations=iterations, warmup_iterations=warmup_iterations, + report_progress_fn=prefill_report_progress_fn, ) time_to_first_token_per_iteration = [ latency for latency, _ in prefill_per_iteration_result ] mean_time_to_first_token = statistics.mean(time_to_first_token_per_iteration) + self.mean_time_to_first_token_list.append(mean_time_to_first_token) + self.prefill_tokens_per_second_list.append( + input_ids.shape[1] / mean_time_to_first_token + ) + try: + self.std_dev_time_to_first_token_list.append( + statistics.stdev(time_to_first_token_per_iteration) + ) + except StatisticsError: + # Less than 2 measurements + self.std_dev_time_to_first_token_list.append(None) + + decode_report_progress_fn = lambda x: report_progress_fn(0.5 + 0.5 * x) # Benchmark generation of all tokens decode_per_iteration_result = benchmark_huggingface_llm( @@ -279,23 +246,19 @@ def run( target_output_tokens=output_tokens, iterations=iterations, warmup_iterations=warmup_iterations, + report_progress_fn=decode_report_progress_fn, ) - mean_execution_latency = statistics.mean( - [latency for latency, _ in decode_per_iteration_result] - ) + execution_latency_per_iteration = [ + latency for latency, _ in decode_per_iteration_result + ] + token_len_per_iteration = [ + token_len for _, token_len in decode_per_iteration_result + ] + mean_execution_latency = statistics.mean(execution_latency_per_iteration) mean_decode_latency = mean_execution_latency - mean_time_to_first_token - mean_token_len = statistics.mean( - [token_len for _, token_len in decode_per_iteration_result] - ) + mean_token_len = statistics.mean(token_len_per_iteration) # Subtract 1 so that we don't count the prefill token - token_generation_tokens_per_second = (mean_token_len - 1) / mean_decode_latency - - # Save performance data to stats - state.save_stat(Keys.SECONDS_TO_FIRST_TOKEN, mean_time_to_first_token) - state.save_stat( - Keys.TOKEN_GENERATION_TOKENS_PER_SECOND, token_generation_tokens_per_second + self.token_generation_tokens_per_second_list.append( + (mean_token_len - 1) / mean_decode_latency ) - state.save_stat(Keys.PROMPT_TOKENS, input_ids.shape[1]) - - return state diff --git a/src/lemonade/tools/huggingface_load.py b/src/lemonade/tools/huggingface_load.py index da98196e..46fd924f 100644 --- a/src/lemonade/tools/huggingface_load.py +++ b/src/lemonade/tools/huggingface_load.py @@ -3,6 +3,7 @@ import json import transformers import torch +from huggingface_hub import model_info from turnkeyml.state import State import turnkeyml.common.status as status from turnkeyml.tools import Tool, FirstTool @@ -59,6 +60,30 @@ def save_pretrained(self, model_dir, **kwargs): return self.tokenizer.save_pretrained(model_dir, **kwargs) +def get_base_model(checkpoint: str) -> Optional[str]: + """ + Get the base model information for a given checkpoint from the Hugging Face Hub. + + Args: + checkpoint: The model checkpoint to query + + Returns: + The base model name if found, or None if not found or error occurs + """ + try: + info = model_info(checkpoint) + if info.cardData and "base_model" in info.cardData: + if info.cardData["base_model"] is not None: + # This is a derived model + return info.cardData["base_model"] + else: + # This is itself a base model + return checkpoint + except Exception: # pylint: disable=broad-except + pass + return None + + class HuggingfaceLoad(FirstTool): """ Load an LLM as a torch.nn.Module using the Hugging Face transformers @@ -204,6 +229,11 @@ def run( state.save_stat(Keys.DTYPE, str(dtype).split(".")[1]) state.save_stat(Keys.DEVICE, device) + # Get base model information + base_model = get_base_model(checkpoint) + if base_model is not None: + state.save_stat("base_model", base_model) + # Create a UniqueInvocationInfo and ModelInfo so that we can display status # at the end of the sequence status.add_to_state(state=state, name=input, model=model) diff --git a/src/lemonade/tools/llamacpp.py b/src/lemonade/tools/llamacpp.py index 464e0522..0b87bc81 100644 --- a/src/lemonade/tools/llamacpp.py +++ b/src/lemonade/tools/llamacpp.py @@ -7,6 +7,7 @@ from turnkeyml.tools import FirstTool from lemonade.tools.adapter import PassthroughTokenizer, ModelAdapter from lemonade.cache import Keys +from lemonade.tools.huggingface_load import get_base_model class LlamaCppAdapter(ModelAdapter): @@ -146,7 +147,7 @@ class LoadLlamaCpp(FirstTool): unique_name = "load-llama-cpp" def __init__(self): - super().__init__(monitor_message="Running llama.cpp model") + super().__init__(monitor_message="Loading llama.cpp model") @staticmethod def parser(add_help: bool = True) -> argparse.ArgumentParser: @@ -245,6 +246,12 @@ def run( # Save stats about the model state.save_stat(Keys.CHECKPOINT, model_to_use) + + # Get base model information if this is a converted HF model + base_model = get_base_model(input) + if base_model is not None: + state.save_stat("base_model", base_model) + status.add_to_state(state=state, name=input, model=model_to_use) return state diff --git a/src/lemonade/tools/llamacpp_bench.py b/src/lemonade/tools/llamacpp_bench.py index bfe8a470..e3c14ee8 100644 --- a/src/lemonade/tools/llamacpp_bench.py +++ b/src/lemonade/tools/llamacpp_bench.py @@ -1,148 +1,77 @@ import argparse -import os import statistics +from statistics import StatisticsError import tqdm from turnkeyml.state import State -from turnkeyml.tools import Tool from lemonade.cache import Keys -import lemonade.tools.ort_genai.oga_bench as general from lemonade.tools.llamacpp import LlamaCppAdapter +from lemonade.tools.bench import Bench -class LlamaCppBench(Tool): +class LlamaCppBench(Bench): + unique_name = "llama-cpp-bench" def __init__(self): - super().__init__(monitor_message="Benchmarking LlamaCPP model") - self.status_stats = [ - Keys.SECONDS_TO_FIRST_TOKEN, - Keys.TOKEN_GENERATION_TOKENS_PER_SECOND, + super().__init__() + + # Additional statistics generated by this bench tool + self.status_stats += [ + Keys.STD_DEV_TOKENS_PER_SECOND, ] + self.std_dev_token_generation_tokens_per_second_list = [] @staticmethod def parser(add_help: bool = True) -> argparse.ArgumentParser: parser = __class__.helpful_parser( - short_description="Benchmark a LLM via llama.cpp", + short_description="Benchmark a Llamacpp model", add_help=add_help, ) - parser.add_argument( - "--prompt", - "-p", - required=False, - default=general.default_prompt, - help="Input prompt to the LLM. Three formats are supported. " - f"1) integer (default: {general.default_prompt}): " - "use a synthetic prompt with the specified length. " - "2) str: use a user-provided prompt string " - "3) path/to/prompt.txt: load the prompt from a text file.", - ) - - context_size = 512 - parser.add_argument( - "--context-size", - required=False, - type=int, - default=context_size, - help=f"Context size of the prompt (default: {context_size})", - ) - - output_tokens = 512 - parser.add_argument( - "--output-tokens", - required=False, - type=int, - default=output_tokens, - help=f"Maximum number of output tokens the LLM should make (default: {output_tokens})", - ) - - default_iterations = 1 - parser.add_argument( - "--iterations", - "-i", - required=False, - type=int, - default=default_iterations, - help=f"Number of benchmarking iterations to run (default: {default_iterations})", - ) - - default_warmup_runs = 0 - parser.add_argument( - "--warmup-iterations", - "-w", - required=False, - type=int, - default=default_warmup_runs, - help="Number of benchmarking iterations to use for cache warmup " - "(the results of these iterations " - f"are not included in the results; default: {default_warmup_runs})", - ) + parser = Bench.parser(parser) return parser - def parse(self, state: State, args, known_only=True) -> argparse.Namespace: - """ - Helper function to parse CLI arguments into the args expected - by run() - """ - - parsed_args = super().parse(state, args, known_only) - - # Decode prompt arg into a string prompt - if parsed_args.prompt.isdigit(): - # Generate a prompt with the requested length - length = int(parsed_args.prompt) - parsed_args.prompt = "word " * (length - 2) - - elif os.path.exists(parsed_args.prompt): - with open(parsed_args.prompt, "r", encoding="utf-8") as f: - parsed_args.prompt = f.read() - - else: - # No change to the prompt - pass - - return parsed_args - - def run( + def run_prompt( self, state: State, - prompt: str = general.default_prompt, - context_size: int = len(general.default_prompt), - output_tokens: int = general.default_output_tokens, - iterations: int = general.default_iterations, - warmup_iterations: int = general.default_warmup_runs, + report_progress_fn, + prompt: str, + iterations: int, + warmup_iterations: int, + output_tokens: int, ) -> State: """ Benchmark llama.cpp model that was loaded by LoadLlamaCpp. """ - # Save benchmarking parameters - state.save_stat("prompt", prompt) - state.save_stat("output_tokens", output_tokens) - state.save_stat("context_size", context_size) - state.save_stat("iterations", iterations) - state.save_stat("warmup_iterations", warmup_iterations) - - if not hasattr(state, "model") or not isinstance(state.model, LlamaCppAdapter): - raise Exception( - f"{self.__class__.unique_name} requires a LlamaCppAdapter model to be " - "loaded first. Please run load-llama-cpp before this tool." - ) + if self.first_run_prompt: + + if not hasattr(state, "model") or not isinstance( + state.model, LlamaCppAdapter + ): + raise Exception( + f"{self.__class__.unique_name} requires a LlamaCppAdapter model to be " + "loaded first. Please run load-llama-cpp before this tool." + ) iteration_tokens_per_second = [] iteration_time_to_first_token = [] for iteration in tqdm.tqdm( - range(iterations), desc="iterations", disable=iterations < 2 + range(iterations + warmup_iterations), + desc="iterations", + disable=iterations < 2, ): try: - # Use the adapter's generate method which already has the timeout and error handling + # Use the adapter's generate method which already has the timeout + # and error handling raw_output, stderr = state.model.generate(prompt, return_raw=True) # Parse the timing information from the output ms_per_token = None time_to_first_token_ms = None + input_tokens = None # Look for timing in both stdout and stderr for output in [raw_output, stderr]: @@ -154,8 +83,11 @@ def run( parts[0].split("ms per token")[0].strip() ) if "llama_perf_context_print: prompt eval time =" in line: - parts = line.split("=")[1].split("/")[0] - time_to_first_token_ms = float(parts.split("ms")[0].strip()) + parts = line.split("=")[1].split("/") + time_to_first_token_ms = float( + parts[0].split("ms")[0].strip() + ) + input_tokens = int(parts[1].split("tokens")[0].strip()) if ms_per_token is None or time_to_first_token_ms is None: error_msg = ( @@ -177,18 +109,47 @@ def run( iteration_tokens_per_second.append(tokens_per_second) iteration_time_to_first_token.append(time_to_first_token) + report_progress_fn((iteration + 1) / (warmup_iterations + iterations)) + except Exception as e: error_msg = f"Failed to run benchmark: {str(e)}" raise Exception(error_msg) - token_generation_tokens_per_second = statistics.mean( - iteration_tokens_per_second - ) + self.input_ids_len_list.append(input_tokens) mean_time_to_first_token = statistics.mean(iteration_time_to_first_token) - - state.save_stat( - Keys.TOKEN_GENERATION_TOKENS_PER_SECOND, token_generation_tokens_per_second + self.mean_time_to_first_token_list.append(mean_time_to_first_token) + self.prefill_tokens_per_second_list.append( + input_tokens / mean_time_to_first_token + ) + self.token_generation_tokens_per_second_list.append( + statistics.mean(iteration_tokens_per_second) ) - state.save_stat(Keys.SECONDS_TO_FIRST_TOKEN, mean_time_to_first_token) + try: + self.std_dev_time_to_first_token_list.append( + statistics.stdev(iteration_time_to_first_token) + ) + except StatisticsError: + # Less than 2 measurements + self.std_dev_time_to_first_token_list.append(None) + try: + self.std_dev_token_generation_tokens_per_second_list.append( + statistics.stdev(iteration_tokens_per_second) + ) + except StatisticsError: + # Less than 2 measurements + self.std_dev_token_generation_tokens_per_second_list.append(None) - return state + def save_stats(self, state): + super().save_stats(state) + + # Save additional statistics + if not all( + element is None + for element in self.std_dev_token_generation_tokens_per_second_list + ): + state.save_stat( + Keys.STD_DEV_TOKENS_PER_SECOND, + self.get_item_or_list( + self.std_dev_token_generation_tokens_per_second_list + ), + ) diff --git a/src/lemonade/tools/mmlu.py b/src/lemonade/tools/mmlu.py index 71ce870f..e46d4932 100644 --- a/src/lemonade/tools/mmlu.py +++ b/src/lemonade/tools/mmlu.py @@ -12,6 +12,7 @@ from turnkeyml.tools import Tool import turnkeyml.common.printing as printing import turnkeyml.common.build as build +import turnkeyml.common.filesystem as fs # Constants choices = ["A", "B", "C", "D"] @@ -212,10 +213,9 @@ def run( # Calculate average of mmlu accuracy and display in the CLI acc_avg = np.mean([accuracy_data["Accuracy"] for accuracy_data in summary_data]) - avg_stat_name = "avg_accuracy" - state.save_stat(avg_stat_name, float(acc_avg) * 100) - state.save_stat("accuracy_units", "%") - self.status_stats.append(avg_stat_name) + state.save_stat(fs.Keys.AVERAGE_MMLU_ACCURACY, float(acc_avg) * 100) + state.save_stat(f"{fs.Keys.AVERAGE_MMLU_ACCURACY}_units", "%") + self.status_stats.append(fs.Keys.AVERAGE_MMLU_ACCURACY) # Save accuracy results to CSV file summary_df = pd.DataFrame(summary_data) diff --git a/src/lemonade/tools/ort_genai/oga.py b/src/lemonade/tools/ort_genai/oga.py index e38a1dad..ed676111 100644 --- a/src/lemonade/tools/ort_genai/oga.py +++ b/src/lemonade/tools/ort_genai/oga.py @@ -23,6 +23,7 @@ from turnkeyml.tools import FirstTool import turnkeyml.common.status as status import turnkeyml.common.printing as printing +from lemonade.tools.huggingface_load import get_base_model from lemonade.tools.adapter import ( ModelAdapter, TokenizerAdapter, @@ -558,6 +559,11 @@ def run( if oga_models_subfolder is not None: state.save_stat(Keys.OGA_MODELS_SUBFOLDER, oga_models_subfolder) + # Get base model information + base_model = get_base_model(checkpoint) + if base_model is not None: + state.save_stat("base_model", base_model) + # Create a UniqueInvocationInfo and ModelInfo so that we can display status # at the end of the sequence status.add_to_state(state=state, name=input, model=input) diff --git a/src/lemonade/tools/ort_genai/oga_bench.py b/src/lemonade/tools/ort_genai/oga_bench.py index 93ae746f..7a312f03 100644 --- a/src/lemonade/tools/ort_genai/oga_bench.py +++ b/src/lemonade/tools/ort_genai/oga_bench.py @@ -1,44 +1,14 @@ import argparse -import os import statistics from statistics import StatisticsError import tqdm from turnkeyml.state import State -from turnkeyml.tools import Tool from lemonade.cache import Keys from lemonade.tools.adapter import ModelAdapter, TokenizerAdapter +from lemonade.tools.bench import Bench -default_iterations = 10 -default_warmup_runs = 5 -default_prompt = "Hello, I am conscious and" -default_beams = 1 -default_output_tokens = 5 - -def not_enough_tokens(output_tokens: int): - """ - Raise an exception that explains why a benchmark did not produce any results - """ - - raise ValueError( - "Your model was benchmarked, however none of the benchmarking " - "iterations produced the requested amount of output tokens " - f"(currently {output_tokens}), so " - "the results have been discarded. You have the following options " - "to solve this: \n" - "1. Use the -p option to change the prompt to something that will " - "produce more output tokens. For example, 'The extremely long " - "story of my life, told in excruciating details is:' " - "is an example of a prompt that will result in a lot of output. \n" - "2. Set a lower value for --output-tokens to make it more likely " - "that the model will produce enough. \n" - "3. Set more verbose hyperparameters. \n" - "4. Run more benchmarking iterations, to improve the chance of " - "getting at least one with enough output tokens. \n" - ) - - -class OgaBench(Tool): +class OgaBench(Bench): """ Benchmark any model that adheres to the ModelAdapter interface. @@ -52,163 +22,115 @@ class OgaBench(Tool): unique_name = "oga-bench" def __init__(self): - super().__init__(monitor_message="Benchmarking LLM") + super().__init__() - self.status_stats = [ - Keys.SECONDS_TO_FIRST_TOKEN, - Keys.STD_DEV_SECONDS_TO_FIRST_TOKEN, - Keys.PREFILL_TOKENS_PER_SECOND, - Keys.TOKEN_GENERATION_TOKENS_PER_SECOND, + # Additional statistics generated by this bench tool + self.status_stats += [ Keys.STD_DEV_TOKENS_PER_SECOND, - Keys.PROMPT_TOKENS, ] + self.std_dev_token_generation_tokens_per_second_list = [] @staticmethod def parser(add_help: bool = True) -> argparse.ArgumentParser: parser = __class__.helpful_parser( - short_description="Benchmark any model that adheres to the lemonade standard", + short_description="Benchmark an LLM in onnxruntime-genai (OGA)", add_help=add_help, ) - parser.add_argument( - "--prompt", - "-p", - required=False, - default=default_prompt, - help="Input prompt to the LLM. Three formats are supported. " - f"1) integer (default: {default_prompt}): " - "use a synthetic prompt with the specified length. " - "2) str: use a user-provided prompt string " - "3) path/to/prompt.txt: load the prompt from a text file.", - ) - - parser.add_argument( - "--iterations", - "-i", - required=False, - type=int, - default=default_iterations, - help=f"Number of benchmarking iterations to run (default: {default_iterations})", - ) - - parser.add_argument( - "--warmup-iterations", - "-w", - required=False, - type=int, - default=default_warmup_runs, - help="Number of benchmarking iterations to use for cache warmup " - "(the results of these iterations " - f"are not included in the results; default: {default_warmup_runs})", - ) - - parser.add_argument( - "--output-tokens", - required=False, - type=int, - default=default_output_tokens, - help=f"Number of new tokens the LLM should make (default: {default_output_tokens})", - ) + parser = Bench.parser(parser) return parser - def parse(self, state: State, args, known_only=True) -> argparse.Namespace: + def get_prompt_str(self, state, token_length): """ - Helper function to parse CLI arguments into the args expected - by run() + Returns a string with the prescribed token length. """ - - parsed_args = super().parse(state, args, known_only) - - # Decode prompt arg into a string prompt - if parsed_args.prompt.isdigit(): - # Generate a prompt with the requested length - length = int(parsed_args.prompt) - parsed_args.prompt = "word " * (length - 2) - - elif os.path.exists(parsed_args.prompt): - with open(parsed_args.prompt, "r", encoding="utf-8") as f: - parsed_args.prompt = f.read() - - else: - # No change to the prompt - pass - - return parsed_args - - def run( + tokenizer: TokenizerAdapter = state.tokenizer + test_prompt = "word " * (token_length - 1) + input_ids = tokenizer(test_prompt, return_tensors="pt").input_ids + test_token_length = len(input_ids) + delta = test_token_length - token_length + if delta == 0: + return test_prompt + return "word " * max(token_length - 1 - delta, 0) + + def run_prompt( self, state: State, - prompt: str = default_prompt, - iterations: int = default_iterations, - warmup_iterations: int = default_warmup_runs, - output_tokens: int = default_output_tokens, + report_progress_fn, + prompt: str, + iterations: int, + warmup_iterations: int, + output_tokens: int, ) -> State: model: ModelAdapter = state.model tokenizer: TokenizerAdapter = state.tokenizer input_ids = tokenizer(prompt, return_tensors="pt").input_ids - if isinstance(input_ids, list): - input_ids_len = len(input_ids) - else: - input_ids_len = input_ids.shape[1] + self.input_ids_len_list.append(len(input_ids)) per_iteration_time_to_first_token = [] per_iteration_tokens_per_second = [] # Don't capture time for warmup - for _ in tqdm.tqdm(range(warmup_iterations), desc="warmup"): + for count in tqdm.tqdm(range(warmup_iterations), desc="warmup"): model.generate(input_ids, max_new_tokens=output_tokens) + report_progress_fn((count + 1) / (warmup_iterations + iterations)) - for _ in tqdm.tqdm(range(iterations), desc="iterations"): + for count in tqdm.tqdm(range(iterations), desc="iterations"): outputs = model.generate( input_ids, max_new_tokens=output_tokens, min_new_tokens=output_tokens, ) + report_progress_fn( + (warmup_iterations + count + 1) / (warmup_iterations + iterations) + ) - token_len = len(outputs[0]) - input_ids_len + token_len = len(outputs[0]) - len(input_ids) - # Only count an iteration it produced enough tokens + # Only count an iteration if it produced enough tokens if token_len >= output_tokens: per_iteration_time_to_first_token.append(model.time_to_first_token) per_iteration_tokens_per_second.append(model.tokens_per_second) if not per_iteration_time_to_first_token or not per_iteration_tokens_per_second: - raise not_enough_tokens(output_tokens) + raise Bench.not_enough_tokens(output_tokens) mean_time_to_first_token = statistics.mean(per_iteration_time_to_first_token) - prefill_tokens_per_second = input_ids_len / mean_time_to_first_token - token_generation_tokens_per_second = statistics.mean( - per_iteration_tokens_per_second + self.mean_time_to_first_token_list.append(mean_time_to_first_token) + self.prefill_tokens_per_second_list.append( + len(input_ids) / mean_time_to_first_token + ) + self.token_generation_tokens_per_second_list.append( + statistics.mean(per_iteration_tokens_per_second) ) try: - std_dev_time_to_first_token = statistics.stdev( - per_iteration_time_to_first_token + self.std_dev_time_to_first_token_list.append( + statistics.stdev(per_iteration_time_to_first_token) ) except StatisticsError: # Less than 2 measurements - std_dev_time_to_first_token = None + self.std_dev_time_to_first_token_list.append(None) try: - std_dev_token_generation_tokens_per_second = statistics.stdev( - per_iteration_tokens_per_second + self.std_dev_token_generation_tokens_per_second_list.append( + statistics.stdev(per_iteration_tokens_per_second) ) except StatisticsError: # Less than 2 measurements - std_dev_token_generation_tokens_per_second = None - - state.save_stat(Keys.SECONDS_TO_FIRST_TOKEN, mean_time_to_first_token) - state.save_stat(Keys.PREFILL_TOKENS_PER_SECOND, prefill_tokens_per_second) - state.save_stat( - Keys.TOKEN_GENERATION_TOKENS_PER_SECOND, token_generation_tokens_per_second - ) - state.save_stat( - Keys.STD_DEV_SECONDS_TO_FIRST_TOKEN, std_dev_time_to_first_token - ) - state.save_stat( - Keys.STD_DEV_TOKENS_PER_SECOND, - std_dev_token_generation_tokens_per_second, - ) - state.save_stat(Keys.PROMPT_TOKENS, input_ids_len) - - return state + self.std_dev_token_generation_tokens_per_second_list.append(None) + + def save_stats(self, state): + super().save_stats(state) + + # Save additional statistics + if not all( + element is None + for element in self.std_dev_token_generation_tokens_per_second_list + ): + state.save_stat( + Keys.STD_DEV_TOKENS_PER_SECOND, + self.get_item_or_list( + self.std_dev_token_generation_tokens_per_second_list + ), + ) diff --git a/src/lemonade/tools/prompt.py b/src/lemonade/tools/prompt.py index 3c093ee4..28a44284 100644 --- a/src/lemonade/tools/prompt.py +++ b/src/lemonade/tools/prompt.py @@ -1,15 +1,5 @@ import argparse import os -import time -import statistics -from threading import Thread, Event -import asyncio -from fastapi import FastAPI, WebSocket -from fastapi.responses import HTMLResponse -from starlette.websockets import WebSocketDisconnect -from pydantic import BaseModel -from transformers import TextIteratorStreamer, StoppingCriteria, StoppingCriteriaList -import uvicorn import matplotlib.pyplot as plt import turnkeyml.common.build as build from turnkeyml.state import State @@ -24,12 +14,9 @@ "temperature": 0.7, } -DEFAULT_SERVER_PORT = 8000 DEFAULT_MAX_NEW_TOKENS = 512 DEFAULT_N_TRIALS = 1 -END_OF_STREAM = "" - def sanitize_string(input_string): return input_string.encode("charmap", "ignore").decode("charmap") @@ -212,334 +199,3 @@ def run( state.save_stat(Keys.RESPONSE, sanitize_text(response_texts)) return state - - -# Custom huggingface-style stopping criteria to allow -# us to halt streaming in-progress generations -class StopOnEvent(StoppingCriteria): - def __init__(self, stop_event: Event): - super().__init__() - self.stop_event = stop_event - - def __call__(self, input_ids, scores, **kwargs): - return self.stop_event.is_set() - - -class Serve(Tool): - """ - Open a web server that apps can use to communicate with the LLM. - - There are two ways to perform generations with the server: - - Send an http request to "http://localhost:8000/generate" and - receive back a response with the complete prompt. - - Open a WebSocket with "ws://localhost:8000" and receive a - streaming response to the prompt. - - The server also exposes these helpful endpoints: - - /health: check whether a model is loaded and ready to serve. - - /stats: performance statistics for the generation. - - /halt: stop an in-progress generation from make more tokens. - - The WebSocket functionality is demonstrated by the webpage served at - http://localhost:8000, which you can visit with a web browser after - opening the server. - - Required input state: - - state.model: model instance serve. Must be compatible with the - huggingface TextIteratorStreamer. - - state.tokenizer: tokenizer instance used to generate inputs for the - model. Must be compatible with the huggingface TextIteratorStreamer. - - state.checkpoint: name of the checkpoint used to load state.model. - - Output state produced: None - """ - - unique_name = "serve" - - def __init__(self): - # Disable the build logger since the server is interactive - super().__init__( - monitor_message="Launching LLM Server", - enable_logger=False, - ) - - # Performance stats that are set during /ws and can be - # fetched in /stats - self.time_to_first_token = None - self.tokens_per_second = None - self.input_tokens = None - self.output_tokens = None - self.decode_token_times = None - - # Flag that tells the LLM to stop generating text and end the response - self.stop_event = Event() - - @staticmethod - def parser(add_help: bool = True) -> argparse.ArgumentParser: - parser = __class__.helpful_parser( - short_description="Open an HTTP server for the model", - add_help=add_help, - ) - - parser.add_argument( - "--max-new-tokens", - required=False, - type=int, - default=300, - help="Number of new tokens the LLM should make (default: 300)", - ) - - return parser - - def run( - self, - state: State, - max_new_tokens: int = 300, - ) -> State: - - # Disable the build monitor since the server is persistent and interactive - if self.progress: - self.progress.terminate() - print("\n") - - app = FastAPI() - - # Load the model and tokenizer - model = state.model - tokenizer = state.tokenizer - - class Message(BaseModel): - text: str - - html = """ - - - - Chat - - -

Lemonade Chat

-
- - -
- - - -

-

- - - - """ - - @app.get("/") - async def get(): - return HTMLResponse(html) - - @app.post("/generate") - async def generate_response(message: Message): - input_ids = tokenizer(message.text, return_tensors="pt").input_ids - response = model.generate( - input_ids, - max_new_tokens=max_new_tokens, - pad_token_id=tokenizer.eos_token_id, - **DEFAULT_GENERATE_PARAMS, - ) - generated_text = tokenizer.decode(response[0], skip_special_tokens=True) - - # Remove the input prompt from the generated text - generated_text = generated_text.replace(message.text, "").strip() - - return {"response": generated_text} - - @app.websocket("/ws") - async def stream_response(websocket: WebSocket): - """ - Receive a prompt string, and then stream the response back - over a websocket. - """ - - await websocket.accept() - while True: - - try: - message = await websocket.receive_text() - except WebSocketDisconnect: - print("Client closed connection") - break - - # Reset the early-exit flag before we start each generation - self.stop_event.clear() - - input_ids = tokenizer(message, return_tensors="pt").input_ids - - # Set up the generation parameters - if isinstance(model, ModelAdapter) and model.type == "ort-genai": - # Onnxruntime-genai models - import lemonade.tools.ort_genai.oga as oga - - streamer = oga.OrtGenaiStreamer(tokenizer) - - self.input_tokens = len(input_ids) - - else: - # Huggingface-like models - streamer = TextIteratorStreamer( - tokenizer, - skip_prompt=True, - ) - - self.input_tokens = len(input_ids[0]) - - # Enable sending a signal into the generator thread to stop - # the generation early - stopping_criteria = StoppingCriteriaList([StopOnEvent(self.stop_event)]) - - generation_kwargs = { - "input_ids": input_ids, - "streamer": streamer, - "max_new_tokens": max_new_tokens, - "pad_token_id": tokenizer.eos_token_id, - "stopping_criteria": stopping_criteria, - **DEFAULT_GENERATE_PARAMS, - } - - # Initialize performance variables - generation_start_time = time.perf_counter() - first_token = True - self.decode_token_times = [] - self.output_tokens = 0 - - # Begin generation - thread = Thread(target=model.generate, kwargs=generation_kwargs) - thread.start() - - # Generate the response using streaming - new_text = "" - for new_text in streamer: - - # Capture performance stats about this token - self.output_tokens = self.output_tokens + 1 - if first_token: - self.time_to_first_token = ( - time.perf_counter() - generation_start_time - ) - first_token = False - else: - self.decode_token_times.append( - time.perf_counter() - next_token_start_time - ) - next_token_start_time = time.perf_counter() - - # Print the decoded value to the terminal for debugging purposes - print(new_text, end="", flush=True) - - # Send the generated text to the client - await asyncio.sleep(0.001) # Add a small delay (adjust as needed) - await websocket.send_text(new_text) - - # Allow the user to finish the response early - if self.stop_event.is_set(): - print("Stopping generation early.") - break - - if new_text != END_OF_STREAM: - await websocket.send_text(END_OF_STREAM) - - self.tokens_per_second = 1 / statistics.mean(self.decode_token_times) - print("\n") - thread.join() - - @app.get("/stats") - async def send_stats(): - """ - Send performance statistics to the client. - """ - return { - "time_to_first_token": self.time_to_first_token, - "tokens_per_second": self.tokens_per_second, - "input_tokens": self.input_tokens, - "output_tokens": self.output_tokens, - "decode_token_times": self.decode_token_times, - } - - @app.get("/halt") - async def halt_generation(): - """ - Allow the client to halt an in-progress generation. - """ - - self.stop_event.set() - - return { - "terminated": True, - } - - @app.get("/health") - async def health(): - """ - Report server health information to the client. - """ - - self.stop_event.set() - - return { - "model_loaded": state.checkpoint, - } - - uvicorn.run(app, host="localhost", port=DEFAULT_SERVER_PORT) - - return state diff --git a/src/lemonade/tools/serve.py b/src/lemonade/tools/serve.py index 985f1885..3879ee1b 100644 --- a/src/lemonade/tools/serve.py +++ b/src/lemonade/tools/serve.py @@ -5,22 +5,37 @@ from threading import Thread, Event from fastapi import FastAPI -from fastapi.responses import HTMLResponse, StreamingResponse +from fastapi.responses import StreamingResponse +from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel import uvicorn import torch # pylint: disable=unused-import from transformers import TextIteratorStreamer, StoppingCriteria, StoppingCriteriaList +from openai.types.chat import ChatCompletion, ChatCompletionChunk +from openai.types.chat.chat_completion_message import ChatCompletionMessage +from openai.types.chat.chat_completion import Choice +from openai.types.chat.chat_completion_chunk import ChoiceDelta +from openai.types.model import Model + from turnkeyml.state import State from turnkeyml.tools.management_tools import ManagementTool from lemonade.tools.adapter import ModelAdapter -from lemonade.tools.chat import DEFAULT_GENERATE_PARAMS +from lemonade.tools.prompt import DEFAULT_GENERATE_PARAMS from lemonade.tools.huggingface_load import HuggingfaceLoad +from lemonade.cache import DEFAULT_CACHE_DIR # Custom huggingface-style stopping criteria to allow # us to halt streaming in-progress generations class StopOnEvent(StoppingCriteria): + """ + Custom stopping criteria that halts text generation when a specified event is set. + + This allows for external control of generation, such as stopping a generation + before it reaches the maximum token limit. + """ + def __init__(self, stop_event: Event): super().__init__() self.stop_event = stop_event @@ -29,43 +44,57 @@ def __call__(self, input_ids, scores, **kwargs): return self.stop_event.is_set() -class CompletionsServerConfig(BaseModel): - cache_dir: str +class LoadConfig(BaseModel): + """ + Configuration for loading a language model. + + Specifies the model checkpoint, cache directory, generation parameters, + and hardware configuration for model loading. + """ + checkpoint: str + cache_dir: str = DEFAULT_CACHE_DIR max_new_tokens: int = 500 - device: str = "hybrid" - dtype: str = "int4" + device: str = "cpu" class CompletionRequest(BaseModel): + """ + Request model for text completion API endpoint. + + Contains the input text to be completed and a model identifier. + """ + text: str - model: str = None + model: str -END_OF_STREAM = "" +class ChatCompletionRequest(BaseModel): + """ + Request model for chat completion API endpoint. + Contains a list of chat messages, a model identifier, + and a streaming flag to control response delivery. + """ + + messages: list[dict] + model: str + stream: bool = False -class ServerPreview(ManagementTool): + +class Server(ManagementTool): """ Open a web server that apps can use to communicate with the LLM. - There are two ways to perform generations with the server: - - Send an http request to "http://localhost:8000/generate" and - receive back a response with the complete prompt. - - Open a WebSocket with "ws://localhost:8000" and receive a - streaming response to the prompt. - - The server also exposes these helpful endpoints: + The server exposes these endpoints: - /api/v0/load: load a model checkpoint - /api/v0/unload: unload a model checkpoint - /api/v0/health: check whether a model is loaded and ready to serve. - /api/v0/stats: performance statistics for the generation. - /api/v0/halt: stop an in-progress generation from make more tokens. - - /api/v0/completions: stream completion responses using HTTP chunked transfer encoding. - - The WebSocket functionality is demonstrated by the webpage served at - http://localhost:8000, which you can visit with a web browser after - opening the server. + - /api/v0/completions: completion responses using HTTP chunked transfer encoding. + - /api/v0/chat/completions: chat completion responses using HTTP chunked transfer encoding. + - /api/v0/models: list all available models. Optional inputs: - --cache-dir: directory to store model artifacts (default: ~/.cache/lemonade) @@ -74,7 +103,7 @@ class ServerPreview(ManagementTool): - --port: port number to run the server on (default: 8000) """ - unique_name = "server-preview" + unique_name = "serve" def __init__(self): super().__init__() @@ -82,14 +111,26 @@ def __init__(self): # Initialize FastAPI app self.app = FastAPI() - # Set up routes + # Add CORS middleware + self.app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Allows all origins + allow_credentials=True, + allow_methods=["*"], # Allows all methods + allow_headers=["*"], # Allows all headers + ) + + # Set up custom routes self.app.post("/api/v0/load")(self.load_llm) self.app.post("/api/v0/unload")(self.unload_llm) self.app.get("/api/v0/health")(self.health) self.app.get("/api/v0/halt")(self.halt_generation) self.app.get("/api/v0/stats")(self.send_stats) self.app.post("/api/v0/completions")(self.completions) - self.app.get("/")(self.get) + + # Set up OpenAI-compatible routes + self.app.post("/api/v0/chat/completions")(self.chat_completions) + self.app.get("/api/v0/models")(self.models) # Performance stats that are set during /ws and can be # fetched in /stats @@ -104,115 +145,52 @@ def __init__(self): # Helpers self.llm_loaded = False + self.tokenizer = None # Placeholders for state and configs self.state = None self.max_new_tokens = None - self.html = """ - - - - Chat - - -

Lemonade Chat

-
- - -
- - - -
-

- - - - """ + # Curated list of "Instruct" and "Chat" models. + self.builtin_models = { + "Qwen2.5-0.5B-Instruct-CPU": { + "checkpoint": "Qwen/Qwen2.5-0.5B-Instruct", + "device": "cpu", + }, + "Llama-3.2-1B-Instruct-Hybrid": { + "checkpoint": "amd/Llama-3.2-1B-Instruct-awq-g128-int4-asym-fp16-onnx-hybrid", + "device": "hybrid", + }, + "Llama-3.2-3B-Instruct-Hybrid": { + "checkpoint": "amd/Llama-3.2-3B-Instruct-awq-g128-int4-asym-fp16-onnx-hybrid", + "device": "hybrid", + }, + "Phi-3.5-Mini-Instruct-Hybrid": { + "checkpoint": "amd/Phi-3.5-mini-instruct-awq-g128-int4-asym-fp16-onnx-hybrid", + "device": "hybrid", + }, + "Phi-3-Mini-Instruct-Hybrid": { + "checkpoint": "amd/Phi-3-mini-4k-instruct-awq-g128-int4-asym-fp16-onnx-hybrid", + "device": "hybrid", + }, + "Llama-2-7B-Chat-Hybrid": { + "checkpoint": "amd/Llama-2-7b-chat-hf-awq-g128-int4-asym-fp16-onnx-hybrid", + "device": "hybrid", + }, + "Qwen-1.5-7B-Chat-Hybrid": { + "checkpoint": "amd/Qwen1.5-7B-Chat-awq-g128-int4-asym-fp16-onnx-hybrid", + "device": "hybrid", + }, + "Mistral-7B-Instruct-Hybrid": { + "checkpoint": "amd/Mistral-7B-Instruct-v0.3-awq-g128-int4-asym-fp16-onnx-hybrid", + "device": "hybrid", + }, + } @staticmethod def parser(add_help: bool = True) -> argparse.ArgumentParser: parser = __class__.helpful_parser( - short_description="Industry Standard Model Server (Preview)", + short_description="Industry Standard Model Server", add_help=add_help, ) @@ -248,7 +226,7 @@ def run( ): # Only load the model when starting the server if checkpoint was provided if checkpoint: - config = CompletionsServerConfig( + config = LoadConfig( cache_dir=cache_dir, checkpoint=checkpoint, max_new_tokens=max_new_tokens, @@ -257,9 +235,6 @@ def run( uvicorn.run(self.app, host="localhost", port=port) - async def get(self): - return HTMLResponse(self.html) - async def completions(self, completion_request: CompletionRequest): """ Stream completion responses using HTTP chunked transfer encoding. @@ -267,9 +242,7 @@ async def completions(self, completion_request: CompletionRequest): if completion_request.model: # Call load_llm with the model name - await self.load_llm( - CompletionsServerConfig(checkpoint=completion_request.model) - ) + await self.load_llm(LoadConfig(checkpoint=completion_request.model)) async def generate(): async for token in self._generate_tokens(completion_request.text): @@ -280,13 +253,124 @@ async def generate(): media_type="text/plain", ) + async def chat_completions(self, chat_completion_request: ChatCompletionRequest): + """ + Stream chat completion responses using HTTP chunked transfer encoding. + """ + + # Get model config + if chat_completion_request.model in self.builtin_models: + model_config = self.builtin_models[chat_completion_request.model] + lc = LoadConfig(**model_config) + else: + # If the model is not built-in, we assume it corresponds to a checkpoint + lc = LoadConfig(checkpoint=chat_completion_request.model) + + if lc.checkpoint != self.llm_loaded: + # Unload the current model if needed + if self.llm_loaded: + await self.unload_llm() + + # Load the new model + await self.load_llm(lc) + + # Convert chat messages to text using the model's chat template + if hasattr(self.tokenizer, "apply_chat_template"): + # Use the model's built-in chat template if available + messages_dict = [ + {"role": msg.get("role", "user"), "content": msg.get("content", "")} + for msg in chat_completion_request.messages + ] + text = self.tokenizer.apply_chat_template( + messages_dict, tokenize=False, add_generation_prompt=True + ) + else: + # Fallback to a standardized template if the model doesn't provide one + formatted_messages = [] + for msg in chat_completion_request.messages: + role = msg.get("role", "user") + content = msg.get("content", "") + role_marker = "<|assistant|>" if role == "assistant" else "<|user|>" + formatted_messages.append(f"{role_marker}\n{content} <|end|>") + text = "\n".join(formatted_messages) + "\n<|assistant|>" + + if chat_completion_request.stream: + + # Stream the response + async def generate(): + async for token in self._generate_tokens(text): + # Create a ChatCompletionChunk + chunk = ChatCompletionChunk.model_construct( + id="0", + object="chat.completion.chunk", + created=int(time.time()), + model=self.llm_loaded, + choices=[ + Choice.model_construct( + index=0, + delta=ChoiceDelta( + content=token, + function_call=None, + role="assistant", + tool_calls=None, + refusal=None, + ), + finish_reason=None, + logprobs=None, + ) + ], + ) + + # Format as SSE + yield f"data: {chunk.model_dump_json()}\n\n".encode("utf-8") + + # Send the [DONE] marker + yield b"data: [DONE]\n\n" + + return StreamingResponse( + generate(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + ) + + # If streaming is not requested, collect all generated tokens into a single response + else: + full_response = "" + async for token in self._generate_tokens(text): + full_response += token + + ccm = ChatCompletionMessage( + content=full_response, + role="assistant", + refusal=None, + audio=None, + function_call=None, + tool_calls=None, + ) + + choice = Choice( + finish_reason="stop", + index=0, + message=ccm, + logprobs=None, + ) + + return ChatCompletion( + id="0", + choices=[choice], + model=self.llm_loaded, + object="chat.completion", + created=int(time.time()), + ) + async def _generate_tokens(self, message: str): """ Core streaming completion logic, separated from response handling. Returns an async generator that yields tokens. """ - if self.state is None: - raise Exception("Model not loaded") model = self.state.model # pylint: disable=no-member tokenizer = self.state.tokenizer # pylint: disable=no-member @@ -316,6 +400,7 @@ async def _generate_tokens(self, message: str): "input_ids": input_ids, "streamer": streamer, "max_new_tokens": self.max_new_tokens, + "min_new_tokens": 1, "pad_token_id": tokenizer.eos_token_id, "stopping_criteria": stopping_criteria, **DEFAULT_GENERATE_PARAMS, @@ -335,8 +420,9 @@ async def _generate_tokens(self, message: str): # Generate the response using streaming new_text = "" for new_text in streamer: - # Add a small delay between tokens to make the streaming more visible - await asyncio.sleep(0.00001) + # Yield control back to the event loop + # This gives the FastAPI server a chance to send the chunks to the client + await asyncio.sleep(0) # Capture performance stats about this token self.output_tokens = self.output_tokens + 1 @@ -351,6 +437,10 @@ async def _generate_tokens(self, message: str): ) next_token_start_time = time.perf_counter() + # Remove the EOS token from the response if needed + if hasattr(self.tokenizer, "eos_token"): + new_text = new_text.replace(self.tokenizer.eos_token, "") + yield new_text # Allow the user to finish the response early @@ -358,11 +448,8 @@ async def _generate_tokens(self, message: str): print("Stopping generation early.") break - if new_text != END_OF_STREAM: - yield END_OF_STREAM - self.tokens_per_second = 1 / statistics.mean(self.decode_token_times) - print("\n") + finally: thread.join() @@ -405,9 +492,9 @@ async def health(self): ), } - async def load_llm(self, config: CompletionsServerConfig): + async def load_llm(self, config: LoadConfig): self.max_new_tokens = config.max_new_tokens - self.llm_loaded = True + print("Loading llm:", config.checkpoint) try: state = State( cache_dir=config.cache_dir, @@ -420,9 +507,7 @@ async def load_llm(self, config: CompletionsServerConfig): state, input=config.checkpoint, device=config.device, - dtype=( - torch.bfloat16 if config.dtype == "bfloat16" else torch.float32 - ), + dtype=torch.bfloat16, ) else: from lemonade.tools.ort_genai.oga import OgaLoad @@ -432,12 +517,12 @@ async def load_llm(self, config: CompletionsServerConfig): state, input=config.checkpoint, device=config.device, - dtype=config.dtype, + dtype="int4", force=True, ) - self.max_new_tokens = config.max_new_tokens - self.llm_loaded = True + self.llm_loaded = config.checkpoint + self.tokenizer = self.state.tokenizer.tokenizer # pylint: disable=no-member return { "status": "success", @@ -461,3 +546,19 @@ async def unload_llm(self): "status": "error", "message": f"Failed to unload model: {str(e)}", } + + async def models(self): + """ + Return a list of available models in OpenAI-compatible format. + """ + models_list = [] + for model in self.builtin_models: + m = Model( + id=model, + owned_by="lemonade", + object="model", + created=int(time.time()), + ) + models_list.append(m) + + return {"object": "list", "data": models_list} diff --git a/src/turnkeyml/common/filesystem.py b/src/turnkeyml/common/filesystem.py index 06e1bfbf..1c4497f0 100644 --- a/src/turnkeyml/common/filesystem.py +++ b/src/turnkeyml/common/filesystem.py @@ -376,6 +376,8 @@ class Keys: INPUTS = "inputs" # Path to the file containing the memory usage plot MEMORY_USAGE_PLOT = "memory_usage_plot" + # Average of all tested MMLU subject scores + AVERAGE_MMLU_ACCURACY = "average_mmlu_accuracy" def _clean_logfile(logfile_lines: List[str]) -> List[str]: diff --git a/src/turnkeyml/common/status.py b/src/turnkeyml/common/status.py index 06f58a71..b8bfa32c 100644 --- a/src/turnkeyml/common/status.py +++ b/src/turnkeyml/common/status.py @@ -22,6 +22,11 @@ def _pretty_print_key(key: str) -> str: return result +class PrettyFloat(float): + def __repr__(self): + return f"{self:0.3f}" + + def parameters_to_size(parameters: int, byte_per_parameter: int = 4) -> str: size_bytes = parameters * byte_per_parameter if size_bytes == 0: @@ -233,7 +238,11 @@ def _print_status(self, cache_dir: str, build_name: str): try: value = stats.stats[key] if isinstance(value, float): - value = "{0:.3f}".format(value) + value = PrettyFloat(value) + elif isinstance(value, list): + value = [ + PrettyFloat(v) if isinstance(v, float) else v for v in value + ] # Tools may provide a unit of measurement for their status # stats, whose key name should follow the format # "STATUS_STATS_KEY_units" diff --git a/src/turnkeyml/sequence/sequence.py b/src/turnkeyml/sequence/sequence.py index b388bcc9..26ffac47 100644 --- a/src/turnkeyml/sequence/sequence.py +++ b/src/turnkeyml/sequence/sequence.py @@ -295,7 +295,7 @@ def launch( if plot_path is not None: printing.log_info(f"Saved plot of memory usage to {plot_path}") state.save_stat(fs.Keys.MEMORY_USAGE_PLOT, plot_path) - else: + elif track_memory_interval is not None: printing.log_info("Error in memory usage tracking, no plot generated") state.save_stat(fs.Keys.MEMORY_USAGE_PLOT, "NONE") diff --git a/src/turnkeyml/version.py b/src/turnkeyml/version.py index a9c316e2..0f607a5d 100644 --- a/src/turnkeyml/version.py +++ b/src/turnkeyml/version.py @@ -1 +1 @@ -__version__ = "5.1.1" +__version__ = "6.0.0" diff --git a/test/lemonade/llm_api.py b/test/lemonade/llm_api.py index f3b42464..b2564527 100644 --- a/test/lemonade/llm_api.py +++ b/test/lemonade/llm_api.py @@ -15,7 +15,7 @@ from lemonade.tools.huggingface_bench import HuggingfaceBench from lemonade.tools.mmlu import AccuracyMMLU from lemonade.tools.humaneval import AccuracyHumaneval -from lemonade.tools.chat import LLMPrompt +from lemonade.tools.prompt import LLMPrompt from lemonade.tools.llamacpp import LoadLlamaCpp from lemonade.tools.llamacpp_bench import LlamaCppBench from lemonade.cache import Keys @@ -204,12 +204,13 @@ def test_003_benchmark(self): iterations=2, warmup_iterations=1, output_tokens=128, - prompt="Hello, I am a test prompt that is long enough to get meaningful metrics.", + prompts=[ + "Hello, I am a test prompt that is long enough to get meaningful metrics." + ], ) - stats = fs.Stats(state.cache_dir, state.build_name).stats - # Check if we got valid metrics + stats = fs.Stats(state.cache_dir, state.build_name).stats self.assertIn(Keys.TOKEN_GENERATION_TOKENS_PER_SECOND, stats) self.assertIn(Keys.SECONDS_TO_FIRST_TOKEN, stats) @@ -361,6 +362,24 @@ def test_006_multiple_prompt_responses(self): ) ) + def test_007_huggingface_multiple_bench(self): + # Benchmark OPT + checkpoint = "facebook/opt-125m" + + state = State( + cache_dir=cache_dir, + build_name="test", + ) + + state = HuggingfaceLoad().run(state, input=checkpoint) + state = HuggingfaceBench().run( + state, iterations=20, prompts=["word " * 30, "word " * 62] + ) + + stats = fs.Stats(state.cache_dir, state.build_name).stats + assert len(stats[Keys.TOKEN_GENERATION_TOKENS_PER_SECOND]) == 2 + assert all(x > 0 for x in stats[Keys.TOKEN_GENERATION_TOKENS_PER_SECOND]) + if __name__ == "__main__": # Get cache directory from environment or create a new one diff --git a/test/lemonade/oga_cpu_api.py b/test/lemonade/oga_cpu_api.py index 32608ae8..c30be8cf 100644 --- a/test/lemonade/oga_cpu_api.py +++ b/test/lemonade/oga_cpu_api.py @@ -6,10 +6,12 @@ import turnkeyml.common.test_helpers as common import turnkeyml.common.filesystem as fs from turnkeyml.common.build import builds_dir +from lemonade.cache import Keys from lemonade.tools.ort_genai.oga import OgaLoad -from lemonade.tools.chat import LLMPrompt +from lemonade.tools.prompt import LLMPrompt from lemonade.tools.mmlu import AccuracyMMLU from lemonade.tools.humaneval import AccuracyHumaneval +from lemonade.tools.ort_genai.oga_bench import OgaBench ci_mode = os.getenv("LEMONADE_CI_MODE", False) @@ -78,6 +80,20 @@ def test_003_accuracy_humaneval(self): stats["humaneval_pass@1"], (int, float) ), "HumanEval pass@1 metric should be numeric" + def test_004_oga_multiple_bench(self): + """Test OgaBench with multiple prompts""" + + state = State(cache_dir=cache_dir, build_name="test") + + state = OgaLoad().run(state, input=checkpoint, device=device, dtype=dtype) + state = OgaBench().run( + state, iterations=20, prompts=["word " * 30, "word " * 62] + ) + + stats = fs.Stats(state.cache_dir, state.build_name).stats + assert len(stats[Keys.TOKEN_GENERATION_TOKENS_PER_SECOND]) == 2 + assert all(x > 0 for x in stats[Keys.TOKEN_GENERATION_TOKENS_PER_SECOND]) + if __name__ == "__main__": cache_dir, _ = common.create_test_dir( diff --git a/test/lemonade/quark_api.py b/test/lemonade/quark_api.py index f518919d..41f0bed2 100644 --- a/test/lemonade/quark_api.py +++ b/test/lemonade/quark_api.py @@ -3,7 +3,7 @@ import os from turnkeyml.state import State import turnkeyml.common.test_helpers as common -from lemonade.tools.chat import LLMPrompt +from lemonade.tools.prompt import LLMPrompt from lemonade.tools.huggingface_load import HuggingfaceLoad from lemonade.tools.quark.quark_quantize import QuarkQuantize from lemonade.tools.quark.quark_load import QuarkLoad @@ -36,13 +36,13 @@ def test_001_quantize(self): "quant_algo": "awq", "quant_scheme": "w_uint4_per_group_asym", "device": "cpu", - "skip_quantization": True + "skip_quantization": True, } # Combine specific quant args with defaults quantize_args = {**self.default_args, **quantize_args} state = QuarkQuantize().run(state, **quantize_args) state = LLMPrompt().run(state, prompt=prompt, max_new_tokens=10) - + assert len(state.response) > 0, state.response diff --git a/test/lemonade/server.py b/test/lemonade/server.py new file mode 100644 index 00000000..07710803 --- /dev/null +++ b/test/lemonade/server.py @@ -0,0 +1,203 @@ +""" +Usage: python server.py + +This will launch the lemonade server, query it in openai mode, +and make sure that the response is valid. + +If you get the `ImportError: cannot import name 'TypeIs' from 'typing_extensions'` error: + 1. pip uninstall typing_extensions + 2. pip install openai +""" + +import unittest +import subprocess +import psutil +import asyncio +import socket +import time +from threading import Thread + +try: + from openai import OpenAI, AsyncOpenAI +except ImportError as e: + raise ImportError("You must `pip install openai` to run this test", e) + +MODEL = "Qwen/Qwen2.5-1.5B-Instruct" +PORT = 8000 + + +def kill_process_on_port(port): + """Kill any process that is using the specified port.""" + killed = False + for proc in psutil.process_iter(["pid", "name"]): + try: + connections = proc.net_connections() + for conn in connections: + if conn.laddr.port == port: + proc_name = proc.name() + proc_pid = proc.pid + proc.kill() + print( + f"Killed process {proc_name} (PID: {proc_pid}) using port {port}" + ) + killed = True + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + continue + + if not killed: + print(f"No process found using port {port}") + + +class Testing(unittest.IsolatedAsyncioTestCase): + def setUp(self): + """ + Start lemonade server process + """ + print("\n=== Starting new test ===") + self.base_url = f"http://localhost:{PORT}/api/v0" + self.messages = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Who won the world series in 2020?"}, + {"role": "assistant", "content": "The LA Dodgers won in 2020."}, + {"role": "user", "content": "In which state was it played?"}, + ] + + # Ensure we kill anything using port 8000 + kill_process_on_port(PORT) + + # Start the lemonade server + lemonade_process = subprocess.Popen( + ["lemonade", "serve"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + + # Print stdout and stderr in real-time + def print_output(): + while True: + stdout = lemonade_process.stdout.readline() + stderr = lemonade_process.stderr.readline() + if stdout: + print(f"[stdout] {stdout.strip()}") + if stderr: + print(f"[stderr] {stderr.strip()}") + if not stdout and not stderr and lemonade_process.poll() is not None: + break + + output_thread = Thread(target=print_output, daemon=True) + output_thread.start() + + # Wait for the server to start by checking port 8000 + start_time = time.time() + while True: + if time.time() - start_time > 60: + raise TimeoutError("Server failed to start within 60 seconds") + try: + conn = socket.create_connection(("localhost", PORT)) + conn.close() + break + except socket.error: + time.sleep(1) + + # Wait a few other seconds after the port is available + time.sleep(5) + + print("Server started successfully") + + self.addCleanup(self.cleanup_lemonade, lemonade_process) + + def cleanup_lemonade(self, server_subprocess: subprocess.Popen): + """ + Kill the lemonade server and stop the model + """ + + # Kill the server subprocess + print("\n=== Cleaning up test ===") + + parent = psutil.Process(server_subprocess.pid) + for child in parent.children(recursive=True): + child.kill() + + server_subprocess.kill() + + kill_process_on_port(PORT) + + def test_001_test_chat_completion(self): + client = OpenAI( + base_url=self.base_url, + api_key="lemonade", # required, but unused + ) + + completion = client.chat.completions.create( + model=MODEL, + messages=self.messages, + ) + + print(completion.choices[0].message.content) + assert len(completion.choices[0].message.content) > 5 + + def test_002_test_chat_completion_streaming(self): + client = OpenAI( + base_url=self.base_url, + api_key="lemonade", # required, but unused + ) + + stream = client.chat.completions.create( + model=MODEL, + messages=self.messages, + stream=True, + ) + complete_response = "" + chunk_count = 0 + for chunk in stream: + if chunk.choices[0].delta.content is not None: + complete_response += chunk.choices[0].delta.content + print(chunk.choices[0].delta.content, end="") + chunk_count += 1 + + assert chunk_count > 5 + assert len(complete_response) > 5 + + async def test_003_test_chat_completion_streaming_async(self): + client = AsyncOpenAI( + base_url=self.base_url, + api_key="lemonade", # required, but unused + ) + + complete_response = "" + stream = await client.chat.completions.create( + model=MODEL, + messages=self.messages, + stream=True, + ) + + chunk_count = 0 + async for chunk in stream: + if chunk.choices[0].delta.content is not None: + complete_response += chunk.choices[0].delta.content + print(chunk.choices[0].delta.content, end="") + chunk_count += 1 + + assert chunk_count > 5 + assert len(complete_response) > 5 + + def test_004_test_models(self): + client = OpenAI( + base_url=self.base_url, + api_key="lemonade", # required, but unused + ) + + # Get the list of models + l = client.models.list() + + # Check that the list is not empty + assert len(l.data) > 0 + + # Check that the list contains the models we expect + assert any(model.id == "Llama-3.2-1B-Instruct-Hybrid" for model in l.data) + + +if __name__ == "__main__": + unittest.main()