Skip to content

Fix resident-AdamW OOM on DRAM_SHARED: serialize device allocations with graph calls (#40) - #42

Merged
gspark-etri merged 3 commits into
mainfrom
fix/40-dram-shared-race
Oct 21, 2026
Merged

gspark-etri merged 3 commits into
mainfrom
fix/40-dram-shared-race

Conversation

@gspark-etri

@gspark-etri gspark-etri commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Background

Full FT with --resident-layers N stopped after 3–13 steps on RNGD with torch.OutOfMemoryError: failed to allocate … bytes on DRAM_SHARED (#40). The failure was intermittent: 2 cards × 8 resident layers failed in 4 of 5 runs, and 1 card × 4 resident layers in 1 of 1. That blocked #34's resident step-time numbers.

Root cause (not out-of-memory). furiosa-torch 2026.2 keeps the device memory type (DRAM / DRAM_SHARED) in one flag for the whole process (furiosa.native_torch.memory._set_dram_shared_mode / _set_memory_type). Every compiled-graph call sets that flag to DRAM_SHARED for a moment. A device allocation from another thread at that moment lands in DRAM_SHARED. On a single-chip card that pool has 0 bytes (the capacity: 0 in the error), so the allocation fails. The layer workers upload bf16 weights while the main thread runs graphs. Resident runs overlap the two the most: the workers start uploading exactly when the main thread runs the resident AdamW graphs.

Evidence (1 card, experiments/data/dram-shared/):

  • Uploads and graph outputs share one 47.5 GiB DRAM pool. Each fills to 47.4 GiB, and mixing them fills to the same total.
  • A DRAM_SHARED mode set in one thread is visible in another. An 8 MB upload in that window reproduces the exact Resident device AdamW: intermittent RNGD out-of-memory (DRAM_SHARED) after 3-13 steps #40 error.
  • A thread polling the flag during 300 x*2 graph calls saw DRAM_SHARED 1,978 times.
  • Graph calls plus 4 uploading threads hit the OOM after 6 graph calls.

Changes

  • npu_lora/backends.py: adds DEVICE_ALLOC_LOCK.
    • FuriosaBackend.compile returns Serialized(m): calls hold the lock, and other attributes pass through.
    • FuriosaBackend.to_device allocates with torch.empty(device=…) under the lock and runs copy_ outside it.
    • CPU and CUDA backends are unchanged.
  • tests/test_cpu.py: test_serialized_graph_call_holds_the_device_alloc_lock.
  • experiments/data/dram-shared/race_repro.py (for Furiosa) and pool_probe.py.
  • Record experiments/2026-10-21-dram-shared-race.md.
  • docs/compiler-workarounds.md gets a runtime section.
  • docs/design.md: step-model rows updated, Resident device AdamW: intermittent RNGD out-of-memory (DRAM_SHARED) after 3-13 steps #40 note replaced.
  • Pointer added in the 10-21 resident record; STATUS.

Verification

  • CI cpu-tests green. Locally I ran the new test and the full-FT / two-device tests.

  • Setup: RNGD, furiosa-torch 2026.2.0, torch 2.10.0+cpu, transformers 5.1.0, driver/firmware 2026.4.0. Llama-3.1-8B-Instruct, S=512, accum 4, lr 1e-5, nsmc_mix, seed 0, 25 steps.

  • Before each run I checked that no process had /dev/rngd* open and that the cards were free in k8s. I used at most 2 cards on npu-node-1 and did not use npu-node-2.

  • Upload speed is unchanged: 117 MB bf16 moves at 6.05 GB/s with t.to and 6.01 GB/s with empty + copy_, and the values match.

  • race_repro.py:

    • without the lock: OOM after 6 graph calls;
    • with the lock: 120 s, 58,799 graph calls and 60,725 uploads, 0 errors;
    • with the [14336, 4096] resident AdamW graph: 120 s, 0 errors, and uploads read back bit-exact.
  • Reliability. All 8 resident runs finished 25/25 steps with no OOM. Losses match the pre-fix resident runs bit for bit.

    configuration runs host load (96 cores)
    2 cards × 8 resident layers per card 3 350–500
    1 card × 4 resident layers 3 380–650
    2 cards × 8, timing run below 1 ~50–80
    1 card × 4, timing run below 1 ~100
  • Full-FT offload is host-bound: measure resident device AdamW and update the placement model #34 timing (median of steps 6–25; host terms from the offload run at the same time):

    run host load step tok/s model error
    2 cards × 8 resident 46–81 4.27 s 480 4.22 s −1.1 %
    2 cards offload, run back to back with the row above 27–71 5.04 s 407 – –
    1 card × 4 resident 27–111 9.72 s 211 10.82 s +11.3 %
    1 card offload, run right before the row above 71–107 10.66 s 192 – –

    2 cards × 8 resident layers is 15 % faster than 2-card offload (+18 % tok/s).

  • Reproduce: commands in the record. The Job generator matches experiments/data/w7/jobgen.py, with labels changed.

Compatibility and limits

  • Graph calls and device allocations are now serialized process-wide. The lock covers only the allocation and the graph submission, not the copy, so the measured overlap is unchanged: the 2-card offload took 5.04 s/step, against 5.09 s before.
  • The runtime still has the bug. Any new code that allocates device tensors from a thread must use be.to_device or take DEVICE_ALLOC_LOCK. Reported to Furiosa via Resident device AdamW: intermittent RNGD out-of-memory (DRAM_SHARED) after 3-13 steps #40 with the repro.
  • The 1-card resident timing was measured only at load ~100. The host did not get below load 60 within 1 h, so that row is contended. The uncontended 6–12-step value of 5.12 s, from before, stays in the docs.
  • Not done: Resident device AdamW: intermittent RNGD out-of-memory (DRAM_SHARED) after 3-13 steps #40's "done when" asks for a 200-step 2-card resident run. That needs another 2-card slot.

Related Issue

Related to #40 (root cause fixed; the 200-step run remains), related to #34 (resident timing and the model check).

🤖 Generated with Claude Code

gspark-etri and others added 3 commits October 21, 2026 22:59
…40)

furiosa-torch 2026.2 keeps the device memory type in one process-wide flag
and every compiled-graph call sets it to DRAM_SHARED for a moment. A layer
worker uploading in that moment allocates in DRAM_SHARED, which is empty on
a single-chip card, and fails with OutOfMemoryError. Graph calls and device
allocations now share one lock; the copy itself stays outside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t runs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fix

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gspark-etri
gspark-etri force-pushed the fix/40-dram-shared-race branch from de00daf to bfb5a7b Compare October 21, 2026 14:02
@gspark-etri
gspark-etri merged commit b3cb03b into main Oct 21, 2026
1 check passed
@gspark-etri
gspark-etri deleted the fix/40-dram-shared-race branch October 21, 2026 14:04
gspark-etri added a commit that referenced this pull request Oct 21, 2026
…card x 4 low-load timing (#34) (#45)

## Background
#42 fixed the cause of #40, a race on furiosa-torch's process-global
DRAM_SHARED flag. That PR showed 25-step runs only. #40's done-when asks
for a 200-step run of `train --full --resident-layers 8 --backend
furiosa:auto,furiosa:auto` with no OOM. #34 still needed the 1 card × 4
resident timing on an uncontended host.

## Changes
- `experiments/2026-10-22-resident-200-steps.md`: record of both runs,
with losses, step times and the step-model check.
- `experiments/data/resident-200/`: step logs (`r200.jsonl`,
`t1c.jsonl`), pre-submit checks and results (`run200.log`), and host
load.
- `docs/design.md`: adds the low-load 1-card row to the step-model table
and notes the 200-step result.
- `STATUS.md` and `.research/events.jsonl` via `report.py`.
- No code changes.

## Verification
- **Code:** main `b3cb03b`, which includes #42 and #43.
- **Hardware:** RNGD on npu-node-1, furiosa-torch 2026.2.0, image digest
`sha256:37a85288…`, driver/firmware 2026.4.0.
- **Recipe:** Llama-3.1-8B-Instruct, S=512, accum 4, lr 1e-5, nsmc_mix,
seed 0.
- **Jobs:** one run-and-exit Job at a time, each with
`activeDeadlineSeconds` and `ttlSecondsAfterFinished`.
- **Pre-submit check before each Job:** host load below 150, the needed
cards free in k8s, and no process holding `/dev/rngd`. The checks logged
load 2–21.

**Run 1: 2 cards × 8 resident layers per card, 200 steps.**
- **200/200 steps, no OOM, on the first attempt.** Host load was 15–50.
- Step 6–200 median: **4.43 s/step, 463 tok/s**. The steps 6–25 median
was also 4.42 s, and the slowest step took 4.92 s, so step time did not
drift.

| steps | mean loss | median step |
|---|---:|---:|
| 1–25 | 1.262 | 4.43 s |
| 26–50 | 1.172 | 4.40 s |
| 51–100 | 1.109 | 4.39 s |
| 101–150 | 1.097 | 4.53 s |
| 151–200 | 1.151 | 4.44 s |

- Step 1 loss is 1.7202. Before #43 it was 1.7157, because #43 changed
the head.

**Run 2: 1 card, same Job, back to back, load 10–44.** Median of steps
6–25:

| run | step | tok/s | model | error |
|---|---:|---:|---:|---:|
| 1-card offload | 5.11 s | 401 | – | – |
| 1 card × 4 resident | **4.79 s** | 427 | 4.94 s | **+3.0 %** |

The model's terms come from the offload run in the same Job.

## Compatibility and limits
- No code changes.
- **Head recompile on the card:** #43 changed the head graphs, so the
200-step pod recompiled two head pieces on the device while holding 2
cards (94 s + 398 s). Next time main changes graphs, I'll run `npu-lora
compile` in a CPU Job first.
- **First submit failed:** the node had lost the `furiosa-llm:latest`
image, so the first submit hit `ImagePullBackOff`. I deleted that Job at
once and re-pulled the image with a pod that holds no card.
- **No same-time 2-card offload baseline:** I used the 2-card slot only
once, so this PR has no 2-card offload run at the same time. The 2-card
model row (−4.4 %) uses the 10-21 `T_dev` from before #43 and is for
reference only.
- **Still open:** reporting the runtime flag to Furiosa, with the repro
from #42 (`experiments/data/dram-shared/race_repro.py`).

## Related Issue
Closes #40. Related to #34.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
gspark-etri added a commit that referenced this pull request Oct 21, 2026
## Background
npu-lora could not train on ATOM (#15). The first version of this PR
compiled 50 of 51 graph parts with
rebel-compiler 0.11.0, but it left two problems:
- one backward part (the SiLU backward) crashed the compiler, so it ran
on the host;
- `npu-lora check --backend rbln:0` failed: the worst LoRA gradient was
16 % off the CPU, against a 5 % threshold.

This update finds the cause of both and makes `check` pass. The branch
is rebased on `main` (#42–#45).

## Changes
- `npu_lora/backends.py` `RBLNBackend` (`--backend rbln:N`):
- Graph parts are compiled with `rebel.compile_from_torch` and run by an
RBLN runtime, with host tensors in and out.
- Compiled parts are cached as `.rbln`. Each part compiles in a child
process, and a part that fails to compile
    falls back to the host.
  - Arguments are mapped by name to the inputs the compiled graph kept.
- **New**: before compiling, `graph.fold_neg_add` rewrites `add(neg(x),
c)` → `rsub(x, c)`. rebel-compiler 0.11
rejects that pair (DEVICE_GRAPH_CONVERSION), and it segfaults when a
matmul follows; this was the SiLU backward.
    The cache path hashes the rewritten graph.
- **New**: `host_parts` / `--host-parts` puts whole part groups (`fwd`,
`bwd`, `rms`, `ce-stats`, `ce-grad`,
    `rms-bwd`) on the host on purpose.
- Default for rbln: `fwd`. The layer forward runs on the host; the layer
backward (which recomputes the forward)
      and the head run on ATOM.
    - `none` puts every part on ATOM.
- Steps report `parts_device_s` / `parts_host_s`. Log lines go to
stderr.
- `npu_lora/cli.py`:
- `check` also reports both runs against an fp32 autograd reference
(`--reference fp32`, dense LoRA). The pass
    criterion is unchanged: NPU vs CPU.
  - `_backend` passes `--cache-dir` and `--host-parts` to rbln.
- `npu_lora/trainer.py`: `LoRATrainer.reference_grads` (fp32 autograd
with the graphs' bf16 weights and adapters).
- Tests (CPU): `fold_neg_add` keeps the SiLU-backward values; host
placement and group parsing; the fp32 reference
  matches HF's loss and the CPU gradients.
- `experiments/2026-10-22-rbln-atom-check.md` +
`experiments/data/rbln-atom/`: the record, raw results, and two
  standalone repros for the vendor.
- README / README.ko: the backend table and Limitations.

## Verification
- Hardware and SDK:
- Host: npu-node-1, one RBLN-CA22 used directly; each run used a card
with no process in `rbln-stat`.
  - rebel-compiler 0.11.0, torch 2.11.0+cpu, transformers 5.8.1.
- Newest SDK found: 0.12.0. optimum-rbln, vllm-rbln and torch-rbln
0.12.0 are on PyPI (2026-10-16) and need
rebel-compiler 0.12.0. That is only on the vendor index, which answers
401 (no credentials on the node), so it
    was not tested.
- Inputs:
- `check`: `unsloth/Llama-3.2-1B-Instruct`, first 2 layers, S=128, LoRA
r16 q·v, B ~ N(0, 0.01), random ids
(check's seed). The diagnostics also varied the ids and B (seeds 2–4).
  - Training: all 16 layers.
- Criteria: `check` passes when the loss is within 1 % and every LoRA
gradient within 5 % of the CPU (unchanged), and
  a LoRA run reduces the loss.
- Method: before choosing a placement, every ATOM part call was also run
on the CPU with the same inputs, and parts
  were moved to the host by group.
- Results:
- CPU suite on Windows / torch 2.9.1: the 3 new or extended tests pass.
8 tests also fail on `main` in that local
environment (transformers 4.57, ai-rm entry points not installed); CI
runs the full suite.
- Compile: before, 1 of 39 backward parts was on the host (segfault).
After, all 59 parts compile. The SiLU-backward
    part is within 0.7 % of the CPU on ATOM.
- `npu-lora check --backend rbln:0 --layers 2 --seq-len 128 --max-mm 2`:

    | placement | loss rel. | worst grad vs CPU | min cos | pass |
    |---|---:|---:|---:|---|
| `--host-parts none` (59 parts on ATOM) | 1.76e-3 | 13.36 % | 0.99114 |
no |
| default (`fwd` on the host: 44 parts on ATOM, 15 on the host) |
1.84e-3 | **1.39 %** | 0.99991 | **yes** |

The default was re-run from the pushed head (`a472883`): same numbers,
exit code 0.
  - Other inputs (seeds 2–4), worst gradient vs CPU:
    - default placement: 4.9 / 1.7 / 2.7 %;
    - all on ATOM: 10.1 / 2.2 / 3.1 %.
- Cause: ATOM rounds away from zero far more often than
round-to-nearest-even (bias +0.5…+0.9), and fp32 outputs
    have bf16-level error (repro table in the record).
- Every part alone is within 0.75 % of the CPU (`ce-grad` up to 4.3 %),
but the forward activations that leave a
      layer drift and the head's softmax amplifies that.
    - Most of the effect comes from the q/k/v projection parts.
- A head on the host does not help (13.4 %). Splitting the head
(`ce-grad` on the host, `lse` from ATOM) makes it
      worse.
- The fp32 reference shows the CPU bf16 run is not exact either: on
check's inputs it is 9.1 % off fp32. On seeds
    2–4 all-ATOM is 1.2–3× further from fp32 than the CPU.
- LoRA, r16 q·v, S=128, default placement vs `--backend cpu`, same code
and seed:
- code-name set (64 examples), 30 steps, lr 5e-4: ATOM 3.85 → 0.54, CPU
3.81 → 0.51.
    - NSMC train, 60 steps, lr 2e-4, mean of steps 1–10 → 51–60:
      - ATOM default 0.470 → 0.094;
      - all on ATOM 0.479 → 0.128;
      - CPU 0.448 → 0.093.
    - The curves track the CPU (median 10-step window difference 4.9 %).
- The reported ATOM loss sits about 0.035 nats above the CPU on
confident tokens, because `lse` comes from ATOM's
      logits and the target logit from the host.
- Median step time, 1B: ATOM 1.39 s, of which ATOM parts 1.09 s and host
parts 0.14 s; CPU 0.79 s. Not tuned: the
    runtime uploads every input, weights included, per call.
  - `.research/check_sensitive.py`: clean.

## Compatibility and limits
- RBLN only. furiosa / cuda / cpu behaviour is unchanged.
- The `rsub` rewrite and host placement apply only to the RBLN backend.
- `check` prints extra fp32 fields; `--reference none` skips them.
- The default RBLN placement keeps the layer forward on the host. That
is a numerics workaround, not a performance
  choice.
- Not measured: quality against A100 / RNGD, and ATOM performance
(per-call weight upload).
- Not tested: rebel-compiler 0.12 (needs vendor-index credentials).
- Vendor bug-report drafts are at the end of the record: `neg` +
`add.Scalar` compile failure, and the rounding bias.
  They are for the lead to send; nothing was sent.

## Related Issue
Closes #15 (`check` passes on 2 layers with the default placement, and a
LoRA run on ATOM reduces the loss). The
compiler and rounding issues are documented with standalone repros.

## Checklist
- [x] One purpose; unrelated formatting, dependency or API changes are
in separate PRs
- [x] Required checks pass locally (new CPU tests) and on ATOM (`check`
pass, LoRA runs)
- [x] No IPs, internal hostnames, tokens or site-specific paths
(`.research/check_sensitive.py`: clean)
- [x] Docs updated (README backend table and limitations, experiment
record)
- [x] AI assistance stated: implemented with Claude Code; reviewed and
run by @gspark-etri

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant