rpeft 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,90 @@
1
+ """Private Python backend for the RPEFT Ruby gem.
2
+
3
+ Imported through PyCall; not a public API. Every facade function takes
4
+ a JSON string and returns a JSON string, so only plain strings cross
5
+ the PyCall boundary.
6
+
7
+ This module must stay importable without torch installed: all heavy
8
+ imports live inside function bodies, and diagnostics() reports what is
9
+ missing instead of failing.
10
+ """
11
+
12
+ import json
13
+ import math
14
+ import os
15
+ import sys
16
+
17
+ __version__ = "0.1.0"
18
+
19
+
20
+ def _dumps(value):
21
+ # json.dumps writes NaN and Infinity, which are not JSON and which the Ruby side cannot parse.
22
+ # A diverged run reports such losses, so they cross as null rather than failing the whole call.
23
+ return json.dumps(_finite(value), allow_nan=False)
24
+
25
+
26
+ def _finite(value):
27
+ if isinstance(value, float) and not math.isfinite(value):
28
+ return None
29
+ if isinstance(value, dict):
30
+ return {key: _finite(item) for key, item in value.items()}
31
+ if isinstance(value, (list, tuple)):
32
+ return [_finite(item) for item in value]
33
+ return value
34
+
35
+
36
+ def _repair_sys_executable():
37
+ # PyCall embeds Python inside the Ruby process, so sys.executable
38
+ # points at the Ruby binary. multiprocessing (used by datasets for
39
+ # its resource tracker) would then spawn Ruby instead of Python.
40
+ # Repair it from the PYTHON environment variable that PyCall honors.
41
+ python = os.environ.get("PYTHON")
42
+ if not python or not os.path.isfile(python):
43
+ return
44
+ if "python" in os.path.basename(sys.executable):
45
+ return
46
+
47
+ import multiprocessing
48
+
49
+ sys.executable = python
50
+ multiprocessing.set_executable(python)
51
+
52
+
53
+ _repair_sys_executable()
54
+
55
+
56
+ def train_lora(config_json):
57
+ from . import _training, _versions
58
+
59
+ _versions.require_minimums()
60
+
61
+ return _dumps(_training.train_lora(json.loads(config_json)))
62
+
63
+
64
+ def merge_lora(config_json):
65
+ from . import _merge, _versions
66
+
67
+ _versions.require_minimums()
68
+
69
+ return _dumps(_merge.merge_lora(json.loads(config_json)))
70
+
71
+
72
+ def generate(config_json):
73
+ from . import _generate, _versions
74
+
75
+ _versions.require_minimums()
76
+
77
+ return _dumps(_generate.generate(json.loads(config_json)))
78
+
79
+
80
+ def detect_device(config_json="{}"):
81
+ from . import _device
82
+
83
+ config = json.loads(config_json)
84
+ return _dumps(_device.resolve_device(config.get("device")))
85
+
86
+
87
+ def diagnostics():
88
+ from . import _diagnostics
89
+
90
+ return _dumps(_diagnostics.diagnostics())
@@ -0,0 +1,79 @@
1
+ def render(tokenizer, messages, add_generation_prompt=False):
2
+ """Renders a chat transcript into the text the model was trained to see.
3
+
4
+ add_generation_prompt marks where the assistant's turn begins. It belongs to
5
+ inference only: training renders the transcript as written.
6
+ """
7
+ _check_roles(messages)
8
+ messages = _without_control_tokens(tokenizer, messages)
9
+ if getattr(tokenizer, "chat_template", None):
10
+ return tokenizer.apply_chat_template(
11
+ messages, tokenize=False, add_generation_prompt=add_generation_prompt
12
+ )
13
+
14
+ # Tokenizers without a chat template (small test models, some base models) still need
15
+ # a deterministic rendering.
16
+ rendered = "\n".join(
17
+ "%s: %s" % (message["role"], message["content"]) for message in messages
18
+ )
19
+ if add_generation_prompt:
20
+ rendered += "\nassistant:"
21
+
22
+ return rendered
23
+
24
+
25
+ def writes_special_tokens(tokenizer):
26
+ """Whether render's output already holds the special tokens, BOS included, that the model expects.
27
+
28
+ A chat template writes them itself, so tokenizing its output must not add them again; the plain
29
+ fallback rendering writes none.
30
+ """
31
+ return bool(getattr(tokenizer, "chat_template", None))
32
+
33
+
34
+ # Kept in step with RPEFT::Messages::ROLES. The Ruby side checks only the first line of a dataset file,
35
+ # so the rest of the file is checked here.
36
+ ROLES = ("system", "user", "assistant")
37
+
38
+
39
+ def _check_roles(messages):
40
+ for message in messages:
41
+ if message.get("role") not in ROLES:
42
+ raise ValueError(
43
+ "message role must be one of %s, got %r" % (", ".join(ROLES), message.get("role"))
44
+ )
45
+
46
+
47
+ def _without_control_tokens(tokenizer, messages):
48
+ # The rendered text is tokenized as a whole, so a control token such as <|im_end|> written inside
49
+ # a message would end the turn there and the rest would be read as a turn of its own. Message text
50
+ # is data, so such tokens are removed from it; only the chat template may produce them.
51
+ tokens = _control_tokens(tokenizer)
52
+ if not tokens:
53
+ return messages
54
+
55
+ return [
56
+ dict(message, content=_strip(message["content"], tokens))
57
+ if isinstance(message.get("content"), str)
58
+ else message
59
+ for message in messages
60
+ ]
61
+
62
+
63
+ def _control_tokens(tokenizer):
64
+ tokens = set(getattr(tokenizer, "all_special_tokens", None) or [])
65
+ for token in (getattr(tokenizer, "added_tokens_decoder", None) or {}).values():
66
+ if getattr(token, "special", False):
67
+ tokens.add(token.content)
68
+ return sorted(tokens, key=len, reverse=True)
69
+
70
+
71
+ def _strip(content, tokens):
72
+ # Repeated until stable, so removing one token cannot join its neighbors into another.
73
+ while True:
74
+ stripped = content
75
+ for token in tokens:
76
+ stripped = stripped.replace(token, "")
77
+ if stripped == content:
78
+ return stripped
79
+ content = stripped
@@ -0,0 +1,59 @@
1
+ import json
2
+
3
+ from . import _chat
4
+ from ._errors import BackendError, stage
5
+
6
+
7
+ def load_tokenized_dataset(path, tokenizer, text_field, max_length, dataset_format="text"):
8
+ with stage("dataset"):
9
+ from datasets import Dataset
10
+
11
+ # Built in memory rather than with load_dataset("json", ...), which writes an Arrow copy of every
12
+ # file into the Hugging Face cache. Each run reads a fresh file, so that copy would never be reused.
13
+ dataset = Dataset.from_list(_read_jsonl(path))
14
+ field = "messages" if dataset_format == "messages" else text_field
15
+ if field not in dataset.column_names:
16
+ raise BackendError(
17
+ "dataset",
18
+ "field '%s' not found in dataset (available: %s)"
19
+ % (field, ", ".join(dataset.column_names)),
20
+ )
21
+
22
+ add_special_tokens = not (dataset_format == "messages" and _chat.writes_special_tokens(tokenizer))
23
+ # A chat template closes every turn with its own end marker. Plain text has none, so without an EOS
24
+ # the model never sees where an answer ends and keeps generating until max_new_tokens.
25
+ eos = tokenizer.eos_token_id if dataset_format != "messages" else None
26
+ content_length = max_length - 1 if eos is not None else max_length
27
+
28
+ def tokenize(batch):
29
+ if dataset_format == "messages":
30
+ texts = [_chat.render(tokenizer, messages) for messages in batch[field]]
31
+ else:
32
+ texts = batch[field]
33
+
34
+ # No padding here: the collator pads each batch dynamically.
35
+ encoded = tokenizer(
36
+ texts, truncation=True, max_length=content_length, add_special_tokens=add_special_tokens
37
+ )
38
+ if eos is not None:
39
+ for input_ids, attention_mask in zip(encoded["input_ids"], encoded["attention_mask"]):
40
+ input_ids.append(eos)
41
+ attention_mask.append(1)
42
+ return encoded
43
+
44
+ return dataset.map(
45
+ tokenize, batched=True, remove_columns=dataset.column_names, keep_in_memory=True
46
+ )
47
+
48
+
49
+ def _read_jsonl(path):
50
+ records = []
51
+ with open(path, encoding="utf-8") as lines:
52
+ for lineno, line in enumerate(lines, 1):
53
+ if not line.strip():
54
+ continue
55
+ try:
56
+ records.append(json.loads(line))
57
+ except json.JSONDecodeError as error:
58
+ raise BackendError("dataset", "line %d is not valid JSON: %s" % (lineno, error))
59
+ return records
@@ -0,0 +1,57 @@
1
+ from ._errors import BackendError, stage
2
+
3
+ _KNOWN_DEVICES = ("cuda", "mps", "cpu")
4
+
5
+
6
+ def resolve_device(override=None):
7
+ with stage("device"):
8
+ import torch
9
+
10
+ if override in (None, "", "auto"):
11
+ device = _detect(torch)
12
+ reason = "cpu fallback" if device == "cpu" else "%s available" % device
13
+ else:
14
+ device = _validate(torch, override)
15
+ reason = "explicit override"
16
+
17
+ return {
18
+ "device": device,
19
+ "dtype": _dtype_for(torch, device),
20
+ "reason": reason,
21
+ "device_name": torch.cuda.get_device_name(0) if device == "cuda" else None,
22
+ }
23
+
24
+
25
+ def _detect(torch):
26
+ if torch.cuda.is_available():
27
+ return "cuda"
28
+ if torch.backends.mps.is_available():
29
+ return "mps"
30
+ return "cpu"
31
+
32
+
33
+ def _validate(torch, requested):
34
+ if requested not in _KNOWN_DEVICES:
35
+ raise BackendError("device", "unknown device: %s" % requested)
36
+ if requested == "cuda" and not torch.cuda.is_available():
37
+ raise BackendError("device", "cuda requested but not available")
38
+ if requested == "mps" and not torch.backends.mps.is_available():
39
+ raise BackendError("device", "mps requested but not available")
40
+ return requested
41
+
42
+
43
+ def _dtype_for(torch, device):
44
+ if device == "cuda" and torch.cuda.is_bf16_supported():
45
+ return "bfloat16"
46
+ # fp16/bf16 on MPS are numerically fragile; v0.1 sticks to float32
47
+ # everywhere except bf16-capable CUDA.
48
+ return "float32"
49
+
50
+
51
+ def empty_cache(device):
52
+ import torch
53
+
54
+ if device == "mps" and hasattr(torch, "mps"):
55
+ torch.mps.empty_cache()
56
+ elif device == "cuda":
57
+ torch.cuda.empty_cache()
@@ -0,0 +1,77 @@
1
+ import importlib
2
+ import importlib.util
3
+ import platform
4
+ import sys
5
+
6
+ LIBRARIES = ("torch", "transformers", "peft", "datasets", "accelerate")
7
+
8
+
9
+ def diagnostics():
10
+ return {
11
+ "python": {
12
+ "executable": sys.executable,
13
+ "version": platform.python_version(),
14
+ },
15
+ "backend_version": _backend_version(),
16
+ "libraries": {name: _library_info(name) for name in LIBRARIES},
17
+ "device": _device_info(),
18
+ }
19
+
20
+
21
+ def _backend_version():
22
+ from . import __version__
23
+
24
+ return __version__
25
+
26
+
27
+ def _library_info(name):
28
+ from . import _versions
29
+
30
+ minimum = _versions.MINIMUMS.get(name)
31
+ if importlib.util.find_spec(name) is None:
32
+ return {"installed": False, "version": None, "minimum": minimum, "outdated": False}
33
+ try:
34
+ module = importlib.import_module(name)
35
+ version = getattr(module, "__version__", None)
36
+ return {
37
+ "installed": True,
38
+ "version": version,
39
+ "minimum": minimum,
40
+ "outdated": _versions.is_outdated(name, version),
41
+ }
42
+ except Exception as error:
43
+ return {
44
+ "installed": False,
45
+ "version": None,
46
+ "minimum": minimum,
47
+ "outdated": False,
48
+ "error": str(error),
49
+ }
50
+
51
+
52
+ def _device_info():
53
+ try:
54
+ import torch
55
+ except Exception:
56
+ return None
57
+
58
+ cuda_available = torch.cuda.is_available()
59
+ mps_available = torch.backends.mps.is_available()
60
+ if cuda_available:
61
+ recommended = "cuda"
62
+ elif mps_available:
63
+ recommended = "mps"
64
+ else:
65
+ recommended = "cpu"
66
+
67
+ return {
68
+ "cuda": {
69
+ "available": cuda_available,
70
+ "device_name": torch.cuda.get_device_name(0) if cuda_available else None,
71
+ },
72
+ "mps": {
73
+ "available": mps_available,
74
+ "built": torch.backends.mps.is_built(),
75
+ },
76
+ "recommended": recommended,
77
+ }
@@ -0,0 +1,24 @@
1
+ import contextlib
2
+
3
+
4
+ class BackendError(RuntimeError):
5
+ """Error whose message carries a machine-readable stage token.
6
+
7
+ The Ruby side parses the "[stage:...]" prefix to pick the matching
8
+ Ruby exception class; see lib/rpeft/backend/error_translation.rb.
9
+ """
10
+
11
+ def __init__(self, stage, message):
12
+ super().__init__("[stage:%s] %s" % (stage, message))
13
+ self.stage = stage
14
+
15
+
16
+ @contextlib.contextmanager
17
+ def stage(name):
18
+ """Tag any exception escaping this block with the given stage name."""
19
+ try:
20
+ yield
21
+ except BackendError:
22
+ raise
23
+ except Exception as error:
24
+ raise BackendError(name, "%s: %s" % (type(error).__name__, error)) from error
@@ -0,0 +1,73 @@
1
+ import gc
2
+
3
+ from . import _chat, _device, _models
4
+ from ._errors import stage
5
+
6
+
7
+ def generate(config):
8
+ resolved = _device.resolve_device(config.get("device"))
9
+ device = resolved["device"]
10
+ dtype = resolved["dtype"]
11
+
12
+ model = None
13
+ try:
14
+ tokenizer, model = _models.load_tokenizer_and_model(
15
+ config["model"],
16
+ dtype=dtype,
17
+ device=device,
18
+ trust_remote_code=config.get("trust_remote_code", False),
19
+ )
20
+
21
+ adapter_path = config.get("adapter_path")
22
+ if adapter_path:
23
+ with stage("model_load"):
24
+ from peft import PeftModel
25
+
26
+ # Applied on top of the base weights; not merged.
27
+ model = PeftModel.from_pretrained(model, adapter_path)
28
+
29
+ with stage("generation"):
30
+ import torch
31
+
32
+ if config.get("seed") is not None:
33
+ torch.manual_seed(config["seed"])
34
+
35
+ # Greedy decoding by default keeps the output deterministic
36
+ # for a given model and prompt, which demo scripts rely on;
37
+ # temperature or top_p switches sampling on.
38
+ generation_options = {
39
+ "max_new_tokens": config.get("max_new_tokens", 64),
40
+ "do_sample": False,
41
+ "pad_token_id": tokenizer.pad_token_id,
42
+ }
43
+ if config.get("temperature") is not None:
44
+ generation_options["temperature"] = float(config["temperature"])
45
+ generation_options["do_sample"] = True
46
+ if config.get("top_p") is not None:
47
+ generation_options["top_p"] = float(config["top_p"])
48
+ generation_options["do_sample"] = True
49
+
50
+ messages = config.get("messages")
51
+ if messages:
52
+ prompt = _chat.render(tokenizer, messages, add_generation_prompt=True)
53
+ add_special_tokens = not _chat.writes_special_tokens(tokenizer)
54
+ else:
55
+ prompt = config["prompt"]
56
+ add_special_tokens = True
57
+
58
+ inputs = tokenizer(prompt, return_tensors="pt", add_special_tokens=add_special_tokens).to(device)
59
+ with torch.no_grad():
60
+ output_ids = model.generate(**inputs, **generation_options)
61
+ completion_ids = output_ids[0][inputs["input_ids"].shape[1]:]
62
+ completion = tokenizer.decode(completion_ids, skip_special_tokens=True)
63
+
64
+ return {
65
+ "completion": str(completion),
66
+ "model": config["model"],
67
+ "device": device,
68
+ "new_tokens": int(completion_ids.shape[0]),
69
+ }
70
+ finally:
71
+ model = None
72
+ gc.collect()
73
+ _device.empty_cache(device)
@@ -0,0 +1,37 @@
1
+ import gc
2
+
3
+ from . import _models
4
+ from ._errors import stage
5
+
6
+
7
+ def merge_lora(config):
8
+ with stage("merge"):
9
+ from peft import PeftConfig, PeftModel
10
+ from transformers import AutoModelForCausalLM
11
+
12
+ adapter_path = config["adapter_path"]
13
+ output_dir = config["output_dir"]
14
+ base_model = config.get("base_model") or PeftConfig.from_pretrained(
15
+ adapter_path
16
+ ).base_model_name_or_path
17
+
18
+ merged = None
19
+ try:
20
+ # Merging is elementwise weight math; CPU is
21
+ # the memory-safest place for it and speed is irrelevant.
22
+ base = AutoModelForCausalLM.from_pretrained(base_model, dtype="auto")
23
+ model = PeftModel.from_pretrained(base, adapter_path)
24
+ merged = model.merge_and_unload()
25
+ merged.save_pretrained(output_dir, safe_serialization=True)
26
+
27
+ # From the base model, the same tokenizer generation with the adapter uses. The adapter
28
+ # directory may carry a tokenizer and chat template of its own, which would otherwise
29
+ # make the merged model behave differently from the adapter it was merged from.
30
+ tokenizer = _models.load_tokenizer(base_model)
31
+ tokenizer.save_pretrained(output_dir)
32
+
33
+ dtype = str(next(merged.parameters()).dtype).replace("torch.", "")
34
+ return {"model_path": output_dir, "base_model": base_model, "dtype": dtype}
35
+ finally:
36
+ merged = None
37
+ gc.collect()
@@ -0,0 +1,30 @@
1
+ from ._errors import stage
2
+
3
+
4
+ def load_tokenizer_and_model(base_model, dtype, device, trust_remote_code=False):
5
+ with stage("model_load"):
6
+ import torch
7
+ from transformers import AutoModelForCausalLM
8
+
9
+ tokenizer = load_tokenizer(base_model, trust_remote_code=trust_remote_code)
10
+ model = AutoModelForCausalLM.from_pretrained(
11
+ base_model,
12
+ dtype=getattr(torch, dtype),
13
+ trust_remote_code=trust_remote_code,
14
+ )
15
+ model = model.to(device)
16
+ model.config.pad_token_id = tokenizer.pad_token_id
17
+
18
+ return tokenizer, model
19
+
20
+
21
+ def load_tokenizer(base_model, trust_remote_code=False):
22
+ from transformers import AutoTokenizer
23
+
24
+ tokenizer = AutoTokenizer.from_pretrained(
25
+ base_model, use_fast=True, trust_remote_code=trust_remote_code
26
+ )
27
+ if tokenizer.pad_token is None:
28
+ tokenizer.pad_token = tokenizer.eos_token
29
+
30
+ return tokenizer