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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ CMake.
**Tools:**
- `tools/warpforth-translate/warpforth-translate.cpp` - Translation tool entry point
- `tools/warpforth-opt/warpforth-opt.cpp` - Optimization tool entry point
- `tools/warpforth-runner/warpforth-runner.cpp` - PTX execution tool for GPU kernels
- `gpu_test/warpforth_runner.py` - PTX execution tool for GPU kernels

## Tools Usage

Expand All @@ -55,8 +55,8 @@ CMake.
./build/bin/warpforth-opt --warpforth-pipeline | \
./build/bin/warpforth-translate --mlir-to-ptx > kernel.ptx

# Execute PTX on GPU
./warpforth-runner kernel.ptx --param i64[]:1,2,3 --param i64:42 --output-param 0 --output-count 3
# Execute PTX on GPU from a JSON request
uv run python gpu_test/warpforth_runner.py kernel.ptx request.json result.json
```

## Adding New Operations
Expand Down
38 changes: 30 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ See the [documentation](https://tetsuo-cpp.github.io/warpforth/).
- CMake
- C++17 compiler
- CUDA toolkit (for GPU execution)
- `cuda-python>=12.8,<13` and an NVIDIA driver (for GPU execution)
- [uv](https://github.com/astral-sh/uv) (for Python test tooling)

## Building
Expand Down Expand Up @@ -56,25 +57,46 @@ Compile to PTX:
./build/bin/warpforthc matmul.forth -o matmul.ptx --arch sm_80
```

Test on a GPU (A is 2x4 row-major, B is 4x3 row-major, C is 2x3 output):
Test on a GPU (A is 2x4 row-major, B is 4x3 row-major, C is 2x3 output).
Save the launch options in `request.json`:

```json
{
"kernel": "main",
"grid": [6, 1, 1],
"block": [1, 1, 1],
"params": [
{"type": "i64[]", "values": [1,2,3,4,5,6,7,8]},
{"type": "i64[]", "values": [1,2,3,4,5,6,7,8,9,10,11,12]},
{"type": "i64[]", "values": [0,0,0,0,0,0]}
],
"outputs": [{"param": 2, "count": 6}]
}
```

```bash
./build/bin/warpforth-runner matmul.ptx \
--param 'i64[]:1,2,3,4,5,6,7,8' \
--param 'i64[]:1,2,3,4,5,6,7,8,9,10,11,12' \
--param 'i64[]:0,0,0,0,0,0' \
--grid 6,1,1 --block 1,1,1 \
--output-param 2 --output-count 6
uv run python gpu_test/warpforth_runner.py matmul.ptx request.json result.json
```

`kernel` is required. `grid` and `block` default to
`[1,1,1]`; `outputs` defaults to `[]`. Each parameter has type `i64` or `f64`
with a scalar `value`, or type `i64[]` or `f64[]` with a nonempty array `values`.
Each output selects an array parameter by zero-based `param` index and may
specify `count` (default: the full array; zero is allowed). Multiple outputs
are returned in request order. Values must be signed 64-bit integers or finite
floats; nonfinite GPU results produce an error rather than invalid JSON.
The runner writes `result.json` with `{"status":"ok","outputs":[{"param":2,"type":"i64[]","values":[70,80,90,158,184,210]}]}`.
On failure it writes `{"status":"error","error":"..."}` to that file and exits
with status 1. No request or result data is sent through stdin or stdout.

## Toolchain

| Tool | Description |
|------|-------------|
| `warpforthc` | Compiles Forth source to PTX |
| `warpforth-translate` | Translates from Forth source to MLIR and MLIR to PTX assembly |
| `warpforth-opt` | Runs individual MLIR passes or entire pipeline |
| `warpforth-runner` | Executes PTX kernels on a GPU for testing |
| `gpu_test/warpforth_runner.py` | Executes PTX kernels on a GPU for testing |

These tools can be composed for debugging or inspecting intermediate stages:

Expand Down
142 changes: 81 additions & 61 deletions gpu_test/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import atexit
import json
import logging
import os
import subprocess
Expand All @@ -18,6 +19,8 @@
from requests import RequestException
from vastai import VastAI

from gpu_test.warpforth_runner import validate_request

if TYPE_CHECKING:
from collections.abc import Generator
from typing import Self
Expand All @@ -26,7 +29,7 @@

PROJECT_ROOT = Path(__file__).resolve().parent.parent
WARPFORTHC = PROJECT_ROOT / "build" / "bin" / "warpforthc"
RUNNER_SRC = PROJECT_ROOT / "tools" / "warpforth-runner" / "warpforth-runner.cpp"
RUNNER_SRC = PROJECT_ROOT / "gpu_test" / "warpforth_runner.py"

MAX_COST_PER_HOUR = 0.50
POLL_INTERVAL_S = 10
Expand Down Expand Up @@ -329,7 +332,7 @@ def _wait_for_ssh(self) -> None:
)
self._attach_ssh_key()
self._wait_for_sshd()
self._compile_runner()
self._install_runner()
return

time.sleep(POLL_INTERVAL_S)
Expand All @@ -356,14 +359,15 @@ def _wait_for_sshd(self) -> None:
)
raise TimeoutError(msg)

def _compile_runner(self) -> None:
"""Upload warpforth-runner.cpp and compile it on the remote host."""
self.scp_upload(RUNNER_SRC, f"{REMOTE_TMP}/warpforth-runner.cpp")
nvcc_cmd = (
f"nvcc -o {REMOTE_TMP}/warpforth-runner"
f" {REMOTE_TMP}/warpforth-runner.cpp -lcuda -std=c++17"
def _install_runner(self) -> None:
"""Upload the Python runner and install its CUDA bindings without nvcc."""
self.scp_upload(RUNNER_SRC, f"{REMOTE_TMP}/warpforth_runner.py")
self.ssh_run(
"apt-get update && apt-get install -y python3 python3-venv && "
f"python3 -m venv {REMOTE_TMP}/warpforth-venv && "
f"{REMOTE_TMP}/warpforth-venv/bin/pip install 'cuda-python>=12.8,<13'",
timeout=300,
)
self.ssh_run(nvcc_cmd, timeout=60)

def _record_labeled_instance_ids(
self,
Expand Down Expand Up @@ -494,8 +498,9 @@ def ssh_run(self, cmd: str, *, timeout: int = 120) -> str:
check=False,
)
if result.returncode != 0:
msg = f"SSH command failed (rc={result.returncode}):\n{result.stderr}"
raise RuntimeError(msg)
raise subprocess.CalledProcessError(
result.returncode, cmd, output=result.stdout, stderr=result.stderr
)
return result.stdout

def scp_upload(self, local_path: str | Path, remote_path: str) -> None:
Expand All @@ -515,6 +520,23 @@ def scp_upload(self, local_path: str | Path, remote_path: str) -> None:
check=True,
)

def scp_download(self, remote_path: str, local_path: str | Path) -> None:
"""Download a file from the remote instance via SCP."""
subprocess.run(
[
"scp",
*self._ssh_options(),
"-P",
str(self.ssh_port),
f"root@{self.ssh_host}:{remote_path}",
str(local_path),
],
capture_output=True,
text=True,
timeout=60,
check=True,
)


def _parse_array_type(type_spec: str) -> tuple[str, int]:
"""Parse 'i64[256]' or 'f64[256]' into (base_type, size)."""
Expand Down Expand Up @@ -603,14 +625,17 @@ def run(
block: tuple[int, int, int] = (1, 1, 1),
output_param: int = 0,
output_count: int | None = None,
) -> list[int] | list[float]:
outputs: list[dict[str, int]] | None = None,
) -> list[int] | list[float] | list[list[int] | list[float]]:
"""Compile Forth source locally, execute on remote GPU, return output values.

Param buffer sizes are derived from the Forth source's 'param' declarations.
The params dict maps param names to initial values:
- Array params: list of int or float (padded with zeros to declared size)
- Scalar params: int or float
Params not in the dict are zero-initialized.
With outputs=[{"param": index, "count": optional_count}, ...], returns
one values list per output. Otherwise returns the single output's values.
"""
# Parse kernel name and param declarations
kernel_name = _parse_kernel_name(forth_source)
Expand All @@ -621,36 +646,10 @@ def run(

params = params or {}

# Validate output_param
if output_param < 0 or output_param >= len(decls):
msg = f"output_param {output_param} out of range (have {len(decls)} params)"
raise ValueError(msg)
if not decls[output_param].is_array:
name = decls[output_param].name
msg = f"output_param {output_param} ('{name}') is a scalar and cannot be read back"
raise ValueError(msg)

# Compile locally
ptx = self.compiler.compile_source(forth_source)

# Write PTX to temp file and upload
with tempfile.NamedTemporaryFile(mode="w", suffix=".ptx", delete=False) as f:
f.write(ptx)
ptx_path = Path(f.name)

try:
self.session.scp_upload(ptx_path, f"{REMOTE_TMP}/kernel.ptx")
finally:
ptx_path.unlink()

# Build remote command
cmd_parts = [
f"{REMOTE_TMP}/warpforth-runner",
f"{REMOTE_TMP}/kernel.ptx",
"--kernel",
kernel_name,
]

wire_params = []
for decl in decls:
if decl.is_array:
values = params.get(decl.name, [])
Expand All @@ -661,35 +660,56 @@ def run(
buf = [zero] * decl.size
for i, v in enumerate(values):
buf[i] = v
cmd_parts.extend(["--param", f"{decl.base_type}[]:{','.join(str(v) for v in buf)}"])
wire_params.append({"type": f"{decl.base_type}[]", "values": buf})
else:
value = params.get(decl.name, 0.0 if decl.base_type == "f64" else 0)
if isinstance(value, list):
msg = f"Scalar param '{decl.name}' expects a scalar, got list"
raise TypeError(msg)
cmd_parts.extend(["--param", f"{decl.base_type}:{value}"])

cmd_parts.extend(
[
"--grid",
f"{grid[0]},{grid[1]},{grid[2]}",
"--block",
f"{block[0]},{block[1]},{block[2]}",
"--output-param",
str(output_param),
]
)
wire_params.append({"type": decl.base_type, "value": value})

single_output = {"param": output_param}
if output_count is not None:
cmd_parts.extend(["--output-count", str(output_count)])

cmd = " ".join(cmd_parts)
stdout = self.session.ssh_run(cmd, timeout=120)

# Parse CSV output — type depends on the output param
out_type = decls[output_param].base_type
parse = float if out_type == "f64" else int
return [parse(v) for v in stdout.strip().split(",")]
single_output["count"] = output_count
request = {
"kernel": kernel_name,
"grid": list(grid),
"block": list(block),
"params": wire_params,
"outputs": outputs if outputs is not None else [single_output],
}
validate_request(request)
response = self._execute(ptx, request)
values = [output["values"] for output in response["outputs"]]
return values if outputs is not None else values[0]

def _execute(self, ptx: str, request: dict) -> dict:
"""Upload input files and download the result, including runner errors."""
with tempfile.TemporaryDirectory() as directory:
ptx_path = Path(directory) / "kernel.ptx"
request_path = Path(directory) / "request.json"
result_path = Path(directory) / "result.json"
ptx_path.write_text(ptx)
request_path.write_text(json.dumps(request, allow_nan=False))
self.session.scp_upload(ptx_path, f"{REMOTE_TMP}/kernel.ptx")
self.session.scp_upload(request_path, f"{REMOTE_TMP}/request.json")
# A failed invocation must never read a previous invocation's result.
remote_result = f"{REMOTE_TMP}/result-{uuid4().hex}.json"
try:
self.session.ssh_run(
f"{REMOTE_TMP}/warpforth-venv/bin/python {REMOTE_TMP}/warpforth_runner.py"
f" {REMOTE_TMP}/kernel.ptx {REMOTE_TMP}/request.json {remote_result}",
timeout=120,
)
except subprocess.CalledProcessError as error:
# Exit 1 writes a runner error file; SSH failures remain transport errors.
if error.returncode != 1:
raise
self.session.scp_download(remote_result, result_path)
response = json.loads(result_path.read_text())
if response["status"] != "ok":
raise RuntimeError(response["error"])
return response


# --- Fixtures ---
Expand Down
11 changes: 11 additions & 0 deletions gpu_test/test_kernels.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@
pytestmark = pytest.mark.gpu


def test_multiple_outputs(kernel_runner: KernelRunner) -> None:
result = kernel_runner.run(
forth_source=(
"\\! kernel main\n\\! param INTS i64[2]\n\\! param FLOATS f64[2]\n"
"42 INTS ! 3.25 FLOATS F!"
),
outputs=[{"param": 1}, {"param": 0, "count": 1}],
)
assert result == [[3.25, 0.0], [42]]


# --- Arithmetic ---


Expand Down
Loading
Loading