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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +364 -0
- data/exe/rpeft +8 -0
- data/lib/rpeft/arguments.rb +29 -0
- data/lib/rpeft/backend/error_translation.rb +28 -0
- data/lib/rpeft/backend/python.rb +161 -0
- data/lib/rpeft/backend.rb +24 -0
- data/lib/rpeft/cli/command.rb +78 -0
- data/lib/rpeft/cli/doctor.rb +75 -0
- data/lib/rpeft/cli/generate.rb +69 -0
- data/lib/rpeft/cli/lora_merge.rb +40 -0
- data/lib/rpeft/cli/lora_train.rb +76 -0
- data/lib/rpeft/cli.rb +77 -0
- data/lib/rpeft/dataset.rb +151 -0
- data/lib/rpeft/device.rb +17 -0
- data/lib/rpeft/diagnosis.rb +44 -0
- data/lib/rpeft/directory.rb +69 -0
- data/lib/rpeft/errors.rb +26 -0
- data/lib/rpeft/generation.rb +63 -0
- data/lib/rpeft/lora/adapter.rb +171 -0
- data/lib/rpeft/lora.rb +204 -0
- data/lib/rpeft/messages.rb +30 -0
- data/lib/rpeft/model.rb +70 -0
- data/lib/rpeft/version.rb +5 -0
- data/lib/rpeft.rb +37 -0
- data/python/rpeft_backend/__init__.py +90 -0
- data/python/rpeft_backend/_chat.py +79 -0
- data/python/rpeft_backend/_data.py +59 -0
- data/python/rpeft_backend/_device.py +57 -0
- data/python/rpeft_backend/_diagnostics.py +77 -0
- data/python/rpeft_backend/_errors.py +24 -0
- data/python/rpeft_backend/_generate.py +73 -0
- data/python/rpeft_backend/_merge.py +37 -0
- data/python/rpeft_backend/_models.py +30 -0
- data/python/rpeft_backend/_training.py +170 -0
- data/python/rpeft_backend/_versions.py +39 -0
- metadata +113 -0
|
@@ -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
|