Skip to content

Commit 1ef470f

Browse files
committed
perf: all
1 parent 7786b0a commit 1ef470f

4 files changed

Lines changed: 136 additions & 149 deletions

File tree

common.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Shared helpers for refusal-direction extraction and ablation.
2+
3+
Both compute_refusal_dir.py and inference.py import from here so the model
4+
choice and loading config live in exactly one place.
5+
"""
6+
import os
7+
8+
import torch
9+
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
10+
11+
# Pick the model once; both scripts use it.
12+
MODEL_ID = "tiiuae/Falcon3-1B-Instruct"
13+
# MODEL_ID = "Qwen/Qwen3-1.7B"
14+
# MODEL_ID = "stabilityai/stablelm-2-zephyr-1_6b"
15+
# MODEL_ID = "Qwen/Qwen1.5-1.8B-Chat"
16+
# MODEL_ID = "Qwen/Qwen-1_8B-chat"
17+
# MODEL_ID = "google/gemma-1.1-2b-it"
18+
# MODEL_ID = "google/gemma-1.1-7b-it"
19+
# MODEL_ID = "meta-llama/Meta-Llama-3-8B-Instruct"
20+
21+
# Let every backend use all CPU cores and the fast matmul kernels.
22+
torch.set_num_threads(os.cpu_count() or 1)
23+
torch.backends.cuda.matmul.allow_tf32 = True
24+
torch.backends.cudnn.allow_tf32 = True
25+
26+
27+
def load_model_and_tokenizer(model_id: str = MODEL_ID):
28+
"""Load a 4-bit quantized causal LM and its tokenizer, ready for inference."""
29+
model = AutoModelForCausalLM.from_pretrained(
30+
model_id,
31+
trust_remote_code=True,
32+
dtype=torch.float16,
33+
device_map="cuda",
34+
quantization_config=BitsAndBytesConfig(
35+
load_in_4bit=True,
36+
bnb_4bit_compute_dtype=torch.float16,
37+
),
38+
)
39+
model.eval()
40+
41+
tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
42+
# Left padding lets us read the last-token hidden state at index -1 for a
43+
# whole batch in one shot.
44+
tokenizer.padding_side = "left"
45+
if tokenizer.pad_token is None:
46+
tokenizer.pad_token = tokenizer.eos_token
47+
48+
return model, tokenizer
49+
50+
51+
def refusal_dir_path(model_id: str = MODEL_ID) -> str:
52+
"""Filename used to cache the refusal direction for a given model."""
53+
return model_id.replace("/", "_") + "_refusal_dir.pt"
54+
55+
56+
def project_out(x: torch.Tensor, direction: torch.Tensor) -> torch.Tensor:
57+
"""Remove the component of `x` that lies along the unit vector `direction`.
58+
59+
proj = (x . d) d ; result = x - proj. Assumes ``direction`` is normalized.
60+
Equivalent to the original einsum, but a plain matmul does the same job.
61+
"""
62+
return x - (x @ direction).unsqueeze(-1) * direction

compute_refusal_dir.py

Lines changed: 37 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,59 @@
11
import random
22

33
import torch
4-
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig
5-
64
from tqdm import tqdm
75

8-
torch.inference_mode()
9-
10-
MODEL_ID = "tiiuae/Falcon3-1B-Instruct"
11-
# MODEL_ID = "Qwen/Qwen3-1.7B"
12-
# MODEL_ID = "stabilityai/stablelm-2-zephyr-1_6b"
13-
# MODEL_ID = "Qwen/Qwen1.5-1.8B-Chat"
14-
# MODEL_ID = "Qwen/Qwen-1_8B-chat"
15-
# MODEL_ID = "google/gemma-1.1-2b-it"
16-
# MODEL_ID = "google/gemma-1.1-7b-it"
17-
# MODEL_ID = "meta-llama/Meta-Llama-3-8B-Instruct"
18-
19-
model = AutoModelForCausalLM.from_pretrained(MODEL_ID,
20-
trust_remote_code=True,
21-
dtype=torch.float16,
22-
device_map="cuda",
23-
quantization_config=BitsAndBytesConfig(load_in_4bit=True,
24-
bnb_4bit_compute_dtype=torch.float16))
25-
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True)
6+
from common import load_model_and_tokenizer, refusal_dir_path
267

278
# settings:
28-
instructions = 32
29-
layer_idx = int(len(model.model.layers) * 0.6)
30-
pos = -1
31-
32-
print("Instruction count: " + str(instructions))
33-
print("Layer index: " + str(layer_idx))
34-
35-
with open("harmful.txt", "r") as f:
36-
harmful = f.readlines()
9+
N_INSTRUCTIONS = 32 # harmful + harmless samples each
10+
BATCH_SIZE = 16 # prompts per forward pass (lower this if you run out of VRAM)
11+
POS = -1 # which token position to read (-1 = last prompt token)
3712

38-
with open("harmless.txt", "r") as f:
39-
harmless = f.readlines()
40-
41-
harmful_instructions = random.sample(harmful, instructions)
42-
harmless_instructions = random.sample(harmless, instructions)
13+
model, tokenizer = load_model_and_tokenizer()
14+
layer_idx = int(len(model.model.layers) * 0.6)
4315

44-
harmful_toks = [
45-
tokenizer.apply_chat_template(conversation=[{"role": "user", "content": insn}],
46-
add_generation_prompt=True,
47-
return_tensors="pt") for insn in harmful_instructions]
48-
harmless_toks = [
49-
tokenizer.apply_chat_template(conversation=[{"role": "user", "content": insn}],
50-
add_generation_prompt=True,
51-
return_tensors="pt") for insn in harmless_instructions]
16+
print(f"Instruction count: {N_INSTRUCTIONS}")
17+
print(f"Layer index: {layer_idx}")
5218

53-
max_its = instructions*2
54-
bar = tqdm(total=max_its)
5519

20+
def load_instructions(path: str) -> list[str]:
21+
with open(path, "r") as f:
22+
return [line.strip() for line in f if line.strip()]
5623

57-
def generate(toks):
58-
bar.update(n=1)
59-
return model.generate(toks.to(model.device),
60-
use_cache=False,
61-
max_new_tokens=1,
62-
return_dict_in_generate=True,
63-
output_hidden_states=True)
6424

25+
harmful = random.sample(load_instructions("harmful.txt"), N_INSTRUCTIONS)
26+
harmless = random.sample(load_instructions("harmless.txt"), N_INSTRUCTIONS)
6527

66-
harmful_outputs = [generate(toks) for toks in harmful_toks]
67-
harmless_outputs = [generate(toks) for toks in harmless_toks]
6828

69-
bar.close()
29+
@torch.inference_mode()
30+
def mean_hidden_state(instructions: list[str]) -> torch.Tensor:
31+
"""Mean last-token hidden state at `layer_idx`, averaged over `instructions`.
7032
71-
harmful_hidden = [output.hidden_states[0][layer_idx][:, pos, :] for output in harmful_outputs]
72-
harmless_hidden = [output.hidden_states[0][layer_idx][:, pos, :] for output in harmless_outputs]
33+
Runs a single batched forward pass per BATCH_SIZE chunk instead of one
34+
generate() call per prompt. Accumulated in float32 for a stable mean.
35+
"""
36+
total = None
37+
for start in tqdm(range(0, len(instructions), BATCH_SIZE)):
38+
batch = instructions[start:start + BATCH_SIZE]
39+
toks = tokenizer.apply_chat_template(
40+
[[{"role": "user", "content": text}] for text in batch],
41+
add_generation_prompt=True,
42+
return_tensors="pt",
43+
padding=True,
44+
return_dict=True,
45+
).to(model.device)
7346

74-
print(harmful_hidden)
47+
hidden = model(**toks, output_hidden_states=True, use_cache=False).hidden_states[layer_idx]
48+
summed = hidden[:, POS, :].float().sum(dim=0)
49+
total = summed if total is None else total + summed
7550

76-
harmful_mean = torch.stack(harmful_hidden).mean(dim=0)
77-
harmless_mean = torch.stack(harmless_hidden).mean(dim=0)
51+
return total / len(instructions)
7852

79-
print(harmful_mean)
8053

81-
refusal_dir = harmful_mean - harmless_mean
54+
refusal_dir = mean_hidden_state(harmful) - mean_hidden_state(harmless)
8255
refusal_dir = refusal_dir / refusal_dir.norm()
8356

84-
print(refusal_dir)
85-
86-
torch.save(refusal_dir, MODEL_ID.replace("/", "_") + "_refusal_dir.pt")
57+
path = refusal_dir_path()
58+
torch.save(refusal_dir, path)
59+
print(f"Saved refusal direction ({refusal_dir.shape[0]} dims) to {path}")

inference.py

Lines changed: 36 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,97 +1,51 @@
1-
import einops
2-
import jaxtyping
31
import torch
4-
import torch.nn as nn
5-
from typing import Optional, Tuple
6-
from transformers import AutoTokenizer, AutoModelForCausalLM, TextStreamer, BitsAndBytesConfig
7-
from inspect import signature
2+
from transformers import TextStreamer
83

9-
torch.inference_mode()
4+
from common import MODEL_ID, load_model_and_tokenizer, project_out, refusal_dir_path
105

11-
MODEL_ID = "tiiuae/Falcon3-1B-Instruct"
12-
# MODEL_ID = "Qwen/Qwen3-1.7B"
13-
# MODEL_ID = "stabilityai/stablelm-2-zephyr-1_6b"
14-
# MODEL_ID = "Qwen/Qwen1.5-1.8B-Chat"
15-
# MODEL_ID = "Qwen/Qwen-1_8B-chat"
16-
# MODEL_ID = "google/gemma-1.1-2b-it"
17-
# MODEL_ID = "google/gemma-1.1-7b-it"
18-
# MODEL_ID = "meta-llama/Meta-Llama-3-8B-Instruct"
6+
model, tokenizer = load_model_and_tokenizer()
197

20-
model = AutoModelForCausalLM.from_pretrained(MODEL_ID,
21-
trust_remote_code=True,
22-
dtype=torch.float16,
23-
device_map="cuda",
24-
quantization_config=BitsAndBytesConfig(load_in_4bit=True,
25-
bnb_4bit_compute_dtype=torch.float16))
26-
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True)
8+
# Cast the direction to the model's device/dtype once, not on every layer call.
9+
refusal_dir = torch.load(refusal_dir_path()).to(device=model.device, dtype=model.dtype)
2710

28-
refusal_dir = torch.load(MODEL_ID.replace("/", "_") + "_refusal_dir.pt")
2911

12+
def ablation_pre_hook(_module, args, kwargs):
13+
"""Project the refusal direction out of the residual stream entering a layer.
3014
31-
def direction_ablation_hook(activation: jaxtyping.Float[torch.Tensor, "... d_act"],
32-
direction: jaxtyping.Float[torch.Tensor, "d_act"]):
33-
proj = einops.einsum(activation, direction.view(-1, 1),
34-
'... d_act, d_act single -> ... single') * direction
35-
return activation - proj
15+
A forward pre-hook on every decoder layer is equivalent to inserting an
16+
ablation layer before each one, but without doubling num_hidden_layers or
17+
wrestling with tuple-vs-tensor return signatures.
18+
"""
19+
if args:
20+
return (project_out(args[0], refusal_dir), *args[1:]), kwargs
21+
kwargs["hidden_states"] = project_out(kwargs["hidden_states"], refusal_dir)
22+
return args, kwargs
3623

3724

38-
# Some model developers thought it was stupid to pass a tuple of tuple of tuples around (rightfully so), but unfortunately now we have a divide
39-
sig = signature(model.model.layers[0].forward)
40-
simple = sig.return_annotation == torch.Tensor
41-
42-
43-
class AblationDecoderLayer(nn.Module):
44-
def __init__(self):
45-
super().__init__()
46-
self.attention_type = "full_attention"
47-
48-
def forward(
49-
self,
50-
hidden_states: torch.Tensor,
51-
attention_mask: Optional[torch.Tensor] = None,
52-
position_ids: Optional[torch.LongTensor] = None,
53-
past_key_value: Optional[Tuple[torch.Tensor]] = None,
54-
output_attentions: Optional[bool] = False,
55-
use_cache: Optional[bool] = False,
56-
cache_position: Optional[torch.LongTensor] = None,
57-
**kwargs,
58-
):
59-
assert not output_attentions
60-
61-
ablated = direction_ablation_hook(hidden_states, refusal_dir.to(
62-
hidden_states.device)).to(hidden_states.device)
63-
64-
if simple:
65-
return ablated
66-
67-
outputs = (ablated,)
68-
69-
if use_cache:
70-
outputs += (past_key_value,)
71-
72-
return outputs
73-
74-
75-
# for qwen 1 this needs to be changed to model.transformer.h
76-
for idx in reversed(range(len(model.model.layers))):
77-
model.model.layers.insert(idx, AblationDecoderLayer())
78-
79-
# bruh
80-
if hasattr(model, "config") and hasattr(model.config, "num_hidden_layers"):
81-
model.config.num_hidden_layers *= 2
25+
# for qwen 1 this needs to be model.transformer.h
26+
for layer in model.model.layers:
27+
layer.register_forward_pre_hook(ablation_pre_hook, with_kwargs=True)
8228

8329
conversation = []
84-
85-
streamer = TextStreamer(tokenizer)
30+
streamer = TextStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
8631

8732
print(f"Chat with {MODEL_ID}:")
8833
while True:
89-
prompt = input()
90-
conversation.append({"role": "user", "content": prompt})
91-
toks = tokenizer.apply_chat_template(conversation=conversation,
92-
add_generation_prompt=True, return_tensors="pt")
93-
94-
gen = model.generate(toks.to(model.device), streamer=streamer, max_new_tokens=1337)
34+
try:
35+
prompt = input("> ")
36+
except (EOFError, KeyboardInterrupt):
37+
break
9538

96-
decoded = tokenizer.batch_decode(gen[0][len(toks[0]):], skip_special_tokens=True)
97-
conversation.append({"role": "assistant", "content": "".join(decoded)})
39+
conversation.append({"role": "user", "content": prompt})
40+
toks = tokenizer.apply_chat_template(
41+
conversation,
42+
add_generation_prompt=True,
43+
return_tensors="pt",
44+
return_dict=True,
45+
).to(model.device)
46+
47+
with torch.inference_mode():
48+
gen = model.generate(**toks, streamer=streamer, max_new_tokens=1337)
49+
50+
reply = tokenizer.decode(gen[0][toks["input_ids"].shape[1]:], skip_special_tokens=True)
51+
conversation.append({"role": "assistant", "content": reply})

requirements.txt

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
jaxtyping
21
transformers
32
tqdm
4-
einops
53
torch
64
bitsandbytes
7-
accelerate
5+
accelerate

0 commit comments

Comments
 (0)