diff --git a/.gitignore b/.gitignore index c598a55..31d97e2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.DS_Store + *.py[cod] # Generated by Cargo diff --git a/README.md b/README.md index 81bcf8c..bd24a2c 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,28 @@ You can install or upgrade `libipld` via pip install -U libipld ``` +### Performance + +Benchmarks against [`cbrrr`](https://github.com/DavidBuchanan314/dag-cbrrr) (C) and [`dag_cbor`](https://github.com/hashberg-io/dag-cbor) (pure Python), measured on the four classic [nativejson-benchmark](https://github.com/miloyip/nativejson-benchmark) fixtures (round-tripped through DAG-CBOR). Bars are operations/second relative to pure-Python `dag_cbor`; higher is better. + +Measured on Apple M1, macOS 15 (Darwin 24.6.0), CPython 3.14.0, `libipld` installed from PyPI (PGO + LTO wheel). + +#### Deserialization + +![deserialization](https://raw.githubusercontent.com/MarshalX/python-libipld/main/benchmark/deserialization.png) + +#### Serialization + +![serialization](https://raw.githubusercontent.com/MarshalX/python-libipld/main/benchmark/serialization.png) + +Reproduce locally: + +```bash +cd benchmark && ./run.sh +``` + +See [`benchmark/README.md`](./benchmark/README.md) for details. + ### Contributing Contributions of all sizes are welcome. diff --git a/benchmark/.gitignore b/benchmark/.gitignore new file mode 100644 index 0000000..21b049d --- /dev/null +++ b/benchmark/.gitignore @@ -0,0 +1,4 @@ +results.json +.benchmarks/ +__pycache__/ +.pytest_cache/ diff --git a/benchmark/README.md b/benchmark/README.md new file mode 100644 index 0000000..335ecad --- /dev/null +++ b/benchmark/README.md @@ -0,0 +1,43 @@ +# benchmark + +DAG-CBOR encode/decode benchmark across Python implementations. + +Compared: +- [`libipld`](https://github.com/MarshalX/python-libipld) (Rust) +- [`cbrrr`](https://github.com/DavidBuchanan314/dag-cbrrr) (C) +- [`py-ipld-dag`](https://github.com/ipld/py-ipld-dag) (Python wrapper over [`cbor2`](https://github.com/agronholm/cbor2) with `canonical=True`; cbor2 itself is Rust) +- [`dag_cbor`](https://github.com/hashberg-io/dag-cbor) (pure Python, used as the 1× baseline) + +Fixtures: `canada.json`, `citm_catalog.json`, `github.json`, `twitter.json` (loaded from `../data/`, parsed once, then encoded to DAG-CBOR via `libipld` for the decode benchmarks). + +## Run + +```sh +./run.sh # encode + decode +./run.sh encode # encode only +./run.sh decode # decode only +``` + +Outputs `serialization.png` and `deserialization.png` next to `chart.py`. Raw history goes into `.benchmarks/` (pytest-benchmark autosave). + +## Which `libipld` is measured? + +By default, `requirements.txt` pulls the **published PGO-optimized wheel** from PyPI. This is what users actually get when they `pip install libipld`, so the charts reflect real-world performance. + +To benchmark a locally-built PGO wheel instead, edit the `libipld` line in `requirements.txt`: + +``` +libipld @ file:///abs/path/to/libipld-*.whl +``` + +Building a local PGO wheel is out of scope here. + +## Filter + +Skip a slow library (e.g. pure-Python `dag_cbor` on `canada`): + +```sh +uv run --with-requirements requirements.txt --with-editable .. \ + pytest --benchmark-enable --benchmark-json=results.json -k "not dag_cbor" +uv run --with-requirements requirements.txt python chart.py results.json +``` diff --git a/benchmark/chart.py b/benchmark/chart.py new file mode 100644 index 0000000..759ed87 --- /dev/null +++ b/benchmark/chart.py @@ -0,0 +1,104 @@ +"""Render bar charts from pytest-benchmark JSON output. + +Usage (run from this directory): + ./run.sh # full pipeline + uv run --with-requirements requirements.txt python chart.py results.json +""" + +import json +import sys +from pathlib import Path + +import matplotlib.pyplot as plt +import pandas as pd +import seaborn as sns +from matplotlib.ticker import FuncFormatter + +BASELINE = 'dag_cbor' +HUE_ORDER = ['libipld', 'cbrrr', 'py-ipld-dag', 'dag_cbor'] + + +def load(path): + with open(path) as f: + data = json.load(f) + + rows = [] + for b in data['benchmarks']: + info = b['extra_info'] + lib = info['lib'] + ver = info.get('version') + rows.append( + { + 'op': info['op'], + 'fixture': info['fixture'], + 'lib': lib, + 'lib_label': f'{lib} {ver}' if ver else lib, + 'ops_per_sec': 1.0 / b['stats']['mean'], + } + ) + + return pd.DataFrame(rows) + + +def add_relative(df, baseline): + out = [] + for (_, _), group in df.groupby(['op', 'fixture']): + # baseline ops/sec for this (op, fixture); fall back to slowest lib if missing + if baseline in group['lib'].values: + base = group.loc[group['lib'] == baseline, 'ops_per_sec'].iloc[0] + else: + base = group['ops_per_sec'].min() + + for _, r in group.iterrows(): + out.append({**r.to_dict(), 'rel': r['ops_per_sec'] / base}) + + return pd.DataFrame(out) + + +def plot(df, op, baseline, title, outfile): + sub = df[df['op'] == op].copy() + if sub.empty: + print(f'skipping {outfile}: no {op} results') + return + + # preserve canonical lib ordering, but resolve to versioned labels for the legend + label_for_lib = dict(zip(sub['lib'], sub['lib_label'])) + labels_present = [label_for_lib[lib] for lib in HUE_ORDER if lib in label_for_lib] + + sns.set_theme(style='darkgrid') + fig, ax = plt.subplots(figsize=(10, 7)) + sns.barplot( + data=sub, + x='fixture', + y='rel', + hue='lib_label', + hue_order=labels_present, + ax=ax, + ) + + ax.axhline(1.0, color='gray', linestyle='--', linewidth=1) + ax.set_title(title) + ax.set_xlabel('Document') + ax.set_ylabel(f'Operations/second relative to {baseline}') + ax.yaxis.set_major_formatter(FuncFormatter(lambda y, _: f'{int(y)}x')) + ax.legend(title='library') + + fig.tight_layout() + fig.savefig(outfile, dpi=150) + print(f'wrote {outfile}') + + +def main(): + path = Path(sys.argv[1] if len(sys.argv) > 1 else 'results.json') + + df = load(path) + baseline = BASELINE if BASELINE in df['lib'].values else df['lib'].iloc[0] + df = add_relative(df, baseline) + + here = Path(__file__).parent + plot(df, 'decode', baseline, 'deserialization', here / 'deserialization.png') + plot(df, 'encode', baseline, 'serialization', here / 'serialization.png') + + +if __name__ == '__main__': + main() diff --git a/benchmark/conftest.py b/benchmark/conftest.py new file mode 100644 index 0000000..6488b42 --- /dev/null +++ b/benchmark/conftest.py @@ -0,0 +1,58 @@ +import json +from importlib.metadata import version +from pathlib import Path + +import pytest + +import libipld + +try: + import cbrrr +except ImportError: + cbrrr = None + +try: + import dag_cbor +except ImportError: + dag_cbor = None + +try: + from dag.codecs import dag_cbor as py_ipld_dag_cbor +except ImportError: + py_ipld_dag_cbor = None + + +FIXTURES = ['canada', 'citm_catalog', 'github', 'twitter'] +DATA_DIR = Path(__file__).parent.parent / 'data' + +DECODERS = {'libipld': libipld.decode_dag_cbor} +ENCODERS = {'libipld': libipld.encode_dag_cbor} +VERSIONS = {'libipld': version('libipld')} +if cbrrr is not None: + DECODERS['cbrrr'] = cbrrr.decode_dag_cbor + ENCODERS['cbrrr'] = cbrrr.encode_dag_cbor + VERSIONS['cbrrr'] = version('cbrrr') +if dag_cbor is not None: + DECODERS['dag_cbor'] = dag_cbor.decode + ENCODERS['dag_cbor'] = dag_cbor.encode + VERSIONS['dag_cbor'] = version('dag-cbor') +if py_ipld_dag_cbor is not None: + DECODERS['py-ipld-dag'] = py_ipld_dag_cbor.decode + ENCODERS['py-ipld-dag'] = py_ipld_dag_cbor.encode + VERSIONS['py-ipld-dag'] = version('py-ipld-dag') + + +@pytest.fixture(scope='session', params=FIXTURES) +def fixture_name(request): + return request.param + + +@pytest.fixture(scope='session') +def fixture_obj(fixture_name): + with open(DATA_DIR / f'{fixture_name}.json') as f: + return json.load(f) + + +@pytest.fixture(scope='session') +def fixture_bytes(fixture_obj): + return libipld.encode_dag_cbor(fixture_obj) diff --git a/benchmark/deserialization.png b/benchmark/deserialization.png new file mode 100644 index 0000000..b5865fa Binary files /dev/null and b/benchmark/deserialization.png differ diff --git a/benchmark/requirements.txt b/benchmark/requirements.txt new file mode 100644 index 0000000..6330d37 --- /dev/null +++ b/benchmark/requirements.txt @@ -0,0 +1,15 @@ +# benchmarks run against the published PGO build by default. +# to bench a local PGO wheel, replace the line below with: +# libipld @ file:///abs/path/to/libipld-*.whl +libipld + +cbrrr>=1.0 +dag-cbor>=0.3 +py-ipld-dag + +pytest>=8.0 +pytest-benchmark>=4.0 +pytest-random-order>=1.1 +matplotlib>=3.8 +seaborn>=0.13 +pandas>=2.2 diff --git a/benchmark/run.sh b/benchmark/run.sh new file mode 100755 index 0000000..cce3681 --- /dev/null +++ b/benchmark/run.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Usage: ./run.sh # runs encode + decode +# ./run.sh encode # runs encode only +# ./run.sh decode # runs decode only + +set -euo pipefail + +cd "$(dirname "$0")" + +TARGET="${1:-}" +if [[ -n "$TARGET" ]]; then + TEST_PATH="test_${TARGET}.py" +else + TEST_PATH="" +fi + +uv run --no-project --with-requirements requirements.txt \ + pytest \ + --verbose \ + --benchmark-enable \ + --benchmark-min-time=1 \ + --benchmark-max-time=5 \ + --benchmark-disable-gc \ + --benchmark-autosave \ + --benchmark-save-data \ + --benchmark-json=results.json \ + --random-order \ + ${TEST_PATH} + +uv run --no-project --with-requirements requirements.txt python chart.py results.json diff --git a/benchmark/serialization.png b/benchmark/serialization.png new file mode 100644 index 0000000..49fa74f Binary files /dev/null and b/benchmark/serialization.png differ diff --git a/benchmark/test_decode.py b/benchmark/test_decode.py new file mode 100644 index 0000000..3f28190 --- /dev/null +++ b/benchmark/test_decode.py @@ -0,0 +1,13 @@ +import pytest + +from conftest import DECODERS, VERSIONS + + +@pytest.mark.parametrize('lib', list(DECODERS)) +def test_decode(benchmark, lib, fixture_name, fixture_bytes): + benchmark.group = f'decode-{fixture_name}' + benchmark.extra_info['op'] = 'decode' + benchmark.extra_info['lib'] = lib + benchmark.extra_info['version'] = VERSIONS[lib] + benchmark.extra_info['fixture'] = fixture_name + benchmark(DECODERS[lib], fixture_bytes) diff --git a/benchmark/test_encode.py b/benchmark/test_encode.py new file mode 100644 index 0000000..1e02f09 --- /dev/null +++ b/benchmark/test_encode.py @@ -0,0 +1,13 @@ +import pytest + +from conftest import ENCODERS, VERSIONS + + +@pytest.mark.parametrize('lib', list(ENCODERS)) +def test_encode(benchmark, lib, fixture_name, fixture_obj): + benchmark.group = f'encode-{fixture_name}' + benchmark.extra_info['op'] = 'encode' + benchmark.extra_info['lib'] = lib + benchmark.extra_info['version'] = VERSIONS[lib] + benchmark.extra_info['fixture'] = fixture_name + benchmark(ENCODERS[lib], fixture_obj) diff --git a/pyproject.toml b/pyproject.toml index ea712bc..ade0484 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ classifiers = [ "Author" = "https://github.com/MarshalX" [dependency-groups] -dev = ["maturin>=1.8.7,<2.0"] +dev = ["maturin>=1.8.7,<2.0", "ruff>=0.8"] testing = [ { include-group = "dev" }, 'pytest==8.3.5; python_version == "3.8"', @@ -74,6 +74,14 @@ module-name = "libipld._libipld" bindings = "pyo3" features = ["pyo3/extension-module"] +[tool.ruff.format] +quote-style = "single" + +[tool.ruff.lint.flake8-quotes] +docstring-quotes = "double" +multiline-quotes = "double" +inline-quotes = "single" + [build-system] requires = ["maturin>=1.8.7,<2.0"] build-backend = "maturin" diff --git a/uv.lock b/uv.lock index 10cf678..671cba0 100644 --- a/uv.lock +++ b/uv.lock @@ -61,12 +61,14 @@ all = [ { name = "pytest-benchmark", version = "5.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pytest-xdist", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pytest-xdist", version = "3.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "ruff" }, ] codspeed = [ { name = "pytest-codspeed", marker = "python_full_version == '3.14.*' and implementation_name == 'cpython'" }, ] dev = [ { name = "maturin" }, + { name = "ruff" }, ] testing = [ { name = "maturin" }, @@ -76,6 +78,7 @@ testing = [ { name = "pytest-benchmark", version = "5.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pytest-xdist", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pytest-xdist", version = "3.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "ruff" }, ] [package.metadata] @@ -89,9 +92,13 @@ all = [ { name = "pytest-benchmark", marker = "python_full_version >= '3.9'", specifier = "==5.2.3" }, { name = "pytest-xdist", marker = "python_full_version == '3.8.*'", specifier = "==3.6.1" }, { name = "pytest-xdist", marker = "python_full_version >= '3.9'", specifier = "==3.8.0" }, + { name = "ruff", specifier = ">=0.8" }, ] codspeed = [{ name = "pytest-codspeed", marker = "python_full_version == '3.14.*' and implementation_name == 'cpython'", specifier = "==5.0.3" }] -dev = [{ name = "maturin", specifier = ">=1.8.7,<2.0" }] +dev = [ + { name = "maturin", specifier = ">=1.8.7,<2.0" }, + { name = "ruff", specifier = ">=0.8" }, +] testing = [ { name = "maturin", specifier = ">=1.8.7,<2.0" }, { name = "pytest", marker = "python_full_version == '3.8.*'", specifier = "==8.3.5" }, @@ -100,6 +107,7 @@ testing = [ { name = "pytest-benchmark", marker = "python_full_version >= '3.9'", specifier = "==5.2.3" }, { name = "pytest-xdist", marker = "python_full_version == '3.8.*'", specifier = "==3.6.1" }, { name = "pytest-xdist", marker = "python_full_version >= '3.9'", specifier = "==3.8.0" }, + { name = "ruff", specifier = ">=0.8" }, ] [[package]] @@ -363,6 +371,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" }, ] +[[package]] +name = "ruff" +version = "0.15.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" }, + { url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" }, + { url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" }, + { url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" }, + { url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" }, + { url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, +] + [[package]] name = "tomli" version = "2.2.1"