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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/ACCELERATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ Compile a component once it is fully configured - the graph captures final dtype

Typical gains are 1.3-1.5x on diffusion transformers, and compilation stacks with the caches above. Notes:

- **First run pays the compile cost.** The [server](SERVER.md)'s persistent worker keeps compiled pipelines loaded between runs, so the cost is paid once per session rather than once per generation.
- **First run pays the compile cost.** The [server](SERVER.md)'s persistent worker keeps compiled pipelines loaded between runs, so the cost is paid once per session rather than once per generation. A new session still retraces, but the kernels it built come back from the on-disk cache, which dw keeps across reboots (`TORCHINDUCTOR_CACHE_DIR`, see [Environment Defaults](#environment-defaults)).
- **Pin the attention backend** on a compiled component (`"attention_backend"` in the same `components` entry) rather than using the pipeline-level per-call context manager, which forces recompiles.
- **Composes with offloading**: apply `group_offload` and `compile` on the same component and the offload hooks are installed first, as required. Skipped with a warning on MPS.
- **Don't combine `fullgraph` with a `cache`**: the cache hooks decide skip-or-compute per step, a data-dependent branch diffusers wraps in `torch.compiler.disable` - it needs the graph break that `fullgraph: true` forbids. Compile with the default (partial) graph mode when a cache is active.
Expand Down Expand Up @@ -279,6 +279,7 @@ Set automatically at import unless already present in the environment (export yo
| `PYTORCH_CUDA_ALLOC_CONF` | `expandable_segments:True` | Lets the CUDA allocator grow segments instead of fragmenting fixed-size ones. Multi-step workflows churn differently-shaped allocations (generate, upscale, interpolate); fragmentation is what OOMs a card that nominally has room. |
| `HF_ENABLE_PARALLEL_LOADING` | `true` | Loads sharded checkpoints in parallel - faster cold starts. |
| `PYTORCH_MPS_HIGH_WATERMARK_RATIO` | `0.0` | MPS only - use all available unified memory. |
| `TORCHINDUCTOR_CACHE_DIR` | `$XDG_CACHE_HOME/dw/torchinductor` (`~/.cache/dw/torchinductor`) | Keeps `torch.compile`'s on-disk cache across reboots - Inductor's own default is under `/tmp`. A new worker still retraces, but a cache hit skips codegen and kernel builds, the bulk of a cold compile. Entries are per shape, so a new size or frame count compiles once more. |

For faster model downloads, optionally `pip install hf_transfer` and set `HF_HUB_ENABLE_HF_TRANSFER=1`. Not enabled automatically - it bypasses the Python HTTP stack and breaks some proxy setups.

Expand Down
18 changes: 18 additions & 0 deletions dw/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,24 @@ def _parallel_loading_default(platform):
if "HF_ENABLE_PARALLEL_LOADING" not in os.environ:
os.environ["HF_ENABLE_PARALLEL_LOADING"] = _parallel_loading_default(sys.platform)


def _inductor_cache_dir_default(environ):
"""Where torch.compile keeps its on-disk cache.

Inductor's own default is /tmp/torchinductor_<user>, which a reboot wipes.
A fresh worker always retraces, but a cache hit skips codegen and kernel
builds - most of a cold compile, about 90s of MiniMax-H3's first step on a
3090 - so the user's cache directory keeps that paid once per shape rather
than once per boot. An empty XDG_CACHE_HOME counts as unset, per the spec."""
base = environ.get("XDG_CACHE_HOME") or os.path.join(
os.path.expanduser("~"), ".cache"
)
return os.path.join(base, "dw", "torchinductor")


if "TORCHINDUCTOR_CACHE_DIR" not in os.environ:
os.environ["TORCHINDUCTOR_CACHE_DIR"] = _inductor_cache_dir_default(os.environ)

# Suppress all common library warnings before any imports
warnings.filterwarnings("ignore", category=FutureWarning)
warnings.filterwarnings("ignore", category=UserWarning)
Expand Down
58 changes: 58 additions & 0 deletions tests/test_inductor_cache_default.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""torch.compile's on-disk cache survives a reboot by default.

Inductor caches compiled graphs and generated kernels under
/tmp/torchinductor_<user> unless TORCHINDUCTOR_CACHE_DIR says otherwise, and
/tmp is wiped on reboot. A fresh worker still has to trace, but a cache hit
skips codegen and kernel builds - the bulk of a cold compile, about 90s of
MiniMax-H3's first step on a 3090. dw points the cache at the user's cache
directory instead, so that cost is paid once per shape rather than once per
boot.
"""

import os
import subprocess
import sys

import dw


def test_follows_xdg_cache_home():
environ = {"XDG_CACHE_HOME": os.path.join("/srv", "cache")}
assert dw._inductor_cache_dir_default(environ) == os.path.join(
"/srv", "cache", "dw", "torchinductor"
)


def test_falls_back_to_home_cache():
expected = os.path.join(os.path.expanduser("~"), ".cache", "dw", "torchinductor")
assert dw._inductor_cache_dir_default({}) == expected


def test_an_empty_xdg_cache_home_is_ignored():
# The XDG spec treats an empty value as unset
expected = os.path.join(os.path.expanduser("~"), ".cache", "dw", "torchinductor")
assert dw._inductor_cache_dir_default({"XDG_CACHE_HOME": ""}) == expected


def _env_after_import(preset=None):
code = "import os, dw; print(os.environ['TORCHINDUCTOR_CACHE_DIR'])"
env = {k: v for k, v in os.environ.items() if k != "TORCHINDUCTOR_CACHE_DIR"}
if preset is not None:
env["TORCHINDUCTOR_CACHE_DIR"] = preset
result = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
env=env,
timeout=120,
)
assert result.returncode == 0, result.stderr
return result.stdout.strip().splitlines()[-1]


def test_import_applies_the_default():
assert _env_after_import() == dw._inductor_cache_dir_default(os.environ)


def test_an_explicit_setting_wins():
assert _env_after_import(preset="/var/tmp/inductor") == "/var/tmp/inductor"
2 changes: 1 addition & 1 deletion ui/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@ Checks, from `ui/`: `npm run check`, `npm run lint`, `npm test`, and `npx playwr

- Show the proof: a list of things that produce images shows the images.
- Every token pair should pass WCAG AA in both themes.
- `--live` marks machine state and nothing else; where it may appear is held by `scripts/design-rules.test.ts`.
- `--live` marks machine state and `--select` the user's place (focus, the open workspace, the active section), each nothing else; where they may appear is held by `scripts/design-rules.test.ts`.
20 changes: 16 additions & 4 deletions ui/scripts/design-rules.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// @vitest-environment node
// Where the --live state colour may appear, held mechanically (ui/CLAUDE.md;
// app.css's header says why --live means machine state and nothing else)
// Where the --live state colour and the --select place colour may appear,
// held mechanically (ui/CLAUDE.md; app.css's header says what each means)
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
Expand All @@ -11,8 +11,8 @@ const UI = join(dirname(fileURLToPath(import.meta.url)), '..')

// The places --live may appear: the header's running link and VRAM
// pressure, a running job's row and status chip, the job page's progress
// bar and current step, the flow view's active step, a model download in
// flight, and the focus ring
// bar and current step, the flow view's active step, and a model download
// in flight
const LIVE_FILES = [
'src/App.svelte',
'src/app.css',
Expand All @@ -29,3 +29,15 @@ it('uses --live only where machine state is shown', () => {
.map((f) => f.slice(UI.length + 1).replaceAll('\\', '/'))
expect(using).toEqual(LIVE_FILES)
})

// The places --select may appear: the focus ring and the sidebar's open
// workspace and active section. It is chrome only - never a surface behind
// an image
const SELECT_FILES = ['src/app.css', 'src/lib/Sidebar.svelte']

it('uses --select only for the focus ring and the nav', () => {
const using = sourceFiles()
.filter((f) => readFileSync(f, 'utf8').includes('var(--select)'))
.map((f) => f.slice(UI.length + 1).replaceAll('\\', '/'))
expect(using).toEqual(SELECT_FILES)
})
52 changes: 25 additions & 27 deletions ui/src/App.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts">
import { poll } from './lib/poll'
import { gbFromMb } from './lib/format'
import { cardReadings, meterText, meterTitle } from './lib/cardMemory'
import {
BookOpen,
Braces,
Expand Down Expand Up @@ -80,8 +80,6 @@
return poll(refreshStatus, 5000)
})

const gb = gbFromMb

type Theme = 'system' | 'light' | 'dark'
let theme = $state<Theme>(
(() => {
Expand All @@ -105,14 +103,11 @@
/* fine - applies for this session */
}
}
const vramPct = $derived.by(() => {
const info = memory?.info
if (!info?.gpu_available || !info.gpu_memory_total_mb) return null
return Math.min(
100,
(100 * (info.gpu_memory_allocated_mb ?? 0)) / info.gpu_memory_total_mb,
)
})
// one meter bar per card, from the per-card readings; the top level of
// /api/memory is only the first card's
const metered = $derived(
cardReadings(memory).filter((c) => c.available && c.pct !== null),
)

// storage.ts namespaces the key: this is localStorage 'dw-sidebar'
let sidebarCollapsed = $state(storageGet<boolean>('sidebar', false))
Expand Down Expand Up @@ -190,30 +185,28 @@
<span class="pulse-dot"></span>running
</a>
{/if}
{#if vramPct !== null}
{#if metered.length}
<button
class="bare vram"
bind:this={statusTrigger}
onclick={() => (statusOpen = !statusOpen)}
title={memory?.info?.gpu_device_name
? `${memory.info.gpu_device_name} - ${gb(memory.info.gpu_memory_allocated_mb ?? 0)} of ${gb(memory.info.gpu_memory_total_mb ?? 0)} GB allocated`
: 'VRAM allocated'}
title={meterTitle(metered) || 'VRAM allocated'}
aria-label="server & worker status"
aria-expanded={statusOpen}
>
<span class="meter">
<span
class="fill"
class:hot={vramPct > 75}
class:critical={vramPct > 92}
style:width={vramPct + '%'}
></span>
<span class="meters">
{#each metered as card, i (card.device ?? i)}
<span class="meter">
<span
class="fill"
class:hot={card.pct! > 75}
class:critical={card.pct! > 92}
style:width={card.pct + '%'}
></span>
</span>
{/each}
</span>
<span class="num vramtext"
>{gb(memory?.info?.gpu_memory_allocated_mb ?? 0)}/{gb(
memory?.info?.gpu_memory_total_mb ?? 0,
)} GB</span
>
<span class="num vramtext">{meterText(metered)}</span>
</button>
{:else}
<button
Expand Down Expand Up @@ -386,6 +379,11 @@
font-size: var(--t-xs);
white-space: nowrap;
}
.meters {
display: flex;
flex-direction: column;
gap: 2px;
}
.meter {
display: block;
width: 54px;
Expand Down
39 changes: 26 additions & 13 deletions ui/src/app.css
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
/* Contact sheet.
*
* This app's whole output is pictures, so the chrome is achromatic on
* purpose: any chroma in the surfaces around an image biases how you read
* This app's whole output is pictures, so the surfaces are achromatic on
* purpose: any chroma in the panels around an image biases how you read
* the colour the model actually produced. Greys here are equal-RGB rather
* than the usual blue-tinted slate, and the generated images are meant to
* be the only saturated thing on screen.
* be the only saturated thing in the content area.
*
* Colour therefore means one thing: machine state. `--live` (a darkroom
* safelight amber) marks what is running, where focus is, and when VRAM is
* under pressure. It is never decoration. Interactive elements are ink -
* the primary button is a solid ink fill, links are ink with an underline -
* so nothing in the chrome competes with the work.
* Colour is therefore spent on three meanings and nothing else:
*
* - `--live` (a darkroom safelight amber) is machine state: what is
* running and when VRAM is under pressure.
* - `--select` (a cool blue) is the user's place: the open workspace, the
* active nav section and the focus ring. It stays in the chrome - the
* sidebar and focus - never in a surface behind an image.
* - a workspace's own dot (`wsColor()` in src/lib/wsColor.ts) identifies
* it in the sidebar and breadcrumb. It is identity, not state.
*
* Where `--live` and `--select` may appear is held by
* scripts/design-rules.test.ts. Interactive elements are ink - the primary
* button is a solid ink fill, links are ink with an underline - so nothing
* in the chrome competes with the work.
*
* Type carries the other rule: anything the ENGINE reads literally (a
* workflow name, a `variable:` reference, a path, a seed, a run id, a
Expand All @@ -33,6 +42,7 @@
--accent: #1c1c1c;
--accent-ink: #ffffff;
--live: #a85b00;
--select: #2b5fb4;
--good: #2f6b3f;
--bad: #a33224;
--warn: #a85b00;
Expand All @@ -51,6 +61,7 @@
--accent: #e8e8e8;
--accent-ink: #131313;
--live: #e8a33d;
--select: #82aaf0;
--good: #6fbf87;
--bad: #e3796a;
--warn: #e8a33d;
Expand All @@ -68,6 +79,7 @@
--accent: #e8e8e8;
--accent-ink: #131313;
--live: #e8a33d;
--select: #82aaf0;
--good: #6fbf87;
--bad: #e3796a;
--warn: #e8a33d;
Expand All @@ -84,6 +96,7 @@
--accent: #1c1c1c;
--accent-ink: #ffffff;
--live: #a85b00;
--select: #2b5fb4;
--good: #2f6b3f;
--bad: #a33224;
--warn: #a85b00;
Expand Down Expand Up @@ -148,11 +161,11 @@ textarea,
filter 0.15s ease,
opacity 0.15s ease;
}
/* Focus is machine state - where the keyboard is - so it gets the signal
colour rather than the interactive ink, which would be invisible against
an ink button. */
/* Focus is the user's place - where the keyboard is - so it takes the
selection colour rather than the interactive ink, which would be
invisible against an ink button. */
:focus-visible {
outline: 2px solid var(--live);
outline: 2px solid var(--select);
outline-offset: 1px;
}

Expand Down Expand Up @@ -293,7 +306,7 @@ select {
}
input:focus,
textarea:focus {
outline: 2px solid var(--live);
outline: 2px solid var(--select);
outline-offset: -1px;
}
/* A checkbox or radio left alone paints in the browser's own blue, which is
Expand Down
Loading
Loading