log and the state index without needless copies 79b5a90ffd43
oxy log read the working copy's metadata through the store's metas cache, which it loads whole; it now uses the change it holds. The metadata walk uses the fast hasher and takes each change's metadata out of the index's map rather than cloning it, sorts unstably (the hash makes the order total), and the shortest unique id prefixes are worked out for the changes shown, against a sorted list, rather than for all. The state index is read into a buffer the file histories point into, not copied a second time, and its change list is hashed on every core. VS Code at 19,978 commits: log -n 20 74 ms to 40 ms, the state index load 43 ms to 28 ms; log and diff output unchanged. refs #94
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-09 22:32 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
79b5a90ffd432e0b213376f08421dd02b1ab8855816d41c6e84d96c9085f851a- Depends on
- 05059d434aca 170bb436e1d5 252e8cb23fbc 2bf22305bc48 36e51aa806c6 3f1b8fe25ec9 4cce912a0953 4d6c9c1b358c 728da0f92161 7505194116e7 75167672ebcd 8fd78eb52ad2 982864068a59 bc489b6c7f69 e02bf0cd0533 f9466021286a f9575918477f fce44ae9ad29 ff0af1ee299d
- Depended on by
- c6e69e4b8d8f 74b4a3c62ea4 bf0247ee3590 5552e69c7325 e8e6e0cac26a
- In bookmarks
- bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
- In tags
- v0.10.0 v0.11.0 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 35 atoms, 0 tree operations, 4 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **A quicker log and diff on long histories (#94, first part).** `oxy log` no longer reads the working copy's metadata through the store's cache, walks the history with a fast hash and without copying each change's metadata, and works out the shortest unique id prefixes for the changes it shows rather than for every change. The state index is read without copying the file histories in it, and its change list is checked on every core. On VS Code at 20 000 commits, `log -n 20` went from 74 ms to 40 ms and loading the state index from 43 ms to 28 ms, with the same output. |
|
| 6 | + | ||
| 5 | 7 | - **Faster commits, and the state index after a checkpoint (#93).** A state loaded through a checkpoint read the changes after it whole, text included, so it could never be saved as the workspace's state index and every command after a checkpoint loaded history the long way (25 s for the first commit on VS Code at 20 000 commits). It now reads them as the index needs them. `oxy commit PATHS` no longer takes a second snapshot to count what it left when it took everything. A one-file commit on VS Code went from about 450 ms to 300 ms; 70 ms of that is the store's fsync, which stays. |
|
| 6 | 8 | ||
| 7 | 9 | - **Reading one file reads one file (#92).** `oxy cat` rendered every file of the state to print one. It now builds only that file, from the workspace's state index when it reads the working copy's base or the state it is on. On VS Code at 20 000 commits, `cat package.json -r main` went from 26 s to 0.10 s and at a change from 2016 from 0.66 s to 0.16 s; on the full 166 000-change history it takes 1.9 s and 1.3 GB, where it ran for minutes and used 9 GB. |
|
| … | 150 unchanged lines |
modified crates/oxy-core/src/repo/mod.rs +46 −20
| … | 1485 unchanged lines |
||
| 1486 | 1486 | heads.push(r.object().ok()?); |
|
| 1487 | 1487 | } |
|
| 1488 | 1488 | let (changes_sum, rest_sum) = (r.object().ok()?, r.object().ok()?); |
|
| 1489 | - | let body = &bytes[4 + 32 * heads.len() + 64..]; |
|
| 1489 | + | let at = 4 + 32 * heads.len() + 64; |
|
| 1490 | + | let body = bytes.get(at..)?; |
|
| 1490 | 1491 | let split = 4 + r.count().ok()?; |
|
| 1491 | 1492 | // Checked whole, so a part decoded later (the tree's maps, a |
|
| 1492 | 1493 | // file's history) can trust its bytes. |
|
| 1493 | 1494 | let (changes, rest) = (body.get(..split)?, body.get(split..)?); |
|
| 1494 | - | let mut h = blake3::Hasher::new(); |
|
| 1495 | - | h.update_rayon(rest); |
|
| 1496 | - | if *blake3::hash(changes).as_bytes() != changes_sum.0 |
|
| 1497 | - | || *h.finalize().as_bytes() != rest_sum.0 |
|
| 1498 | - | { |
|
| 1495 | + | let sum = |b: &[u8]| *blake3::Hasher::new().update_rayon(b).finalize().as_bytes(); |
|
| 1496 | + | if sum(changes) != changes_sum.0 || sum(rest) != rest_sum.0 { |
|
| 1499 | 1497 | return None; |
|
| 1500 | 1498 | } |
|
| 1501 | - | Some((State::read_index(body).ok()?, heads)) |
|
| 1499 | + | // The histories point into this buffer rather than copying it. |
|
| 1500 | + | Some(( |
|
| 1501 | + | State::read_index_in(&std::sync::Arc::new(bytes), at).ok()?, |
|
| 1502 | + | heads, |
|
| 1503 | + | )) |
|
| 1502 | 1504 | } |
|
| 1503 | 1505 | ||
| 1504 | 1506 | /// The changes of this workspace's state index (the closure of its |
|
| … | 15 unchanged lines |
||
| 1520 | 1522 | let mut changes = vec![0u8; 4 + len]; |
|
| 1521 | 1523 | changes[..4].copy_from_slice(&head[32 * n + 64..]); |
|
| 1522 | 1524 | f.read_exact(&mut changes[4..]).ok()?; |
|
| 1523 | - | if blake3::hash(&changes).as_bytes() != sum { |
|
| 1525 | + | let checking = crate::timing::phase("load: of which checking"); |
|
| 1526 | + | let mut h = blake3::Hasher::new(); |
|
| 1527 | + | h.update_rayon(&changes); |
|
| 1528 | + | if h.finalize().as_bytes() != sum { |
|
| 1524 | 1529 | return None; |
|
| 1525 | 1530 | } |
|
| 1531 | + | drop(checking); |
|
| 1532 | + | let _decoding = crate::timing::phase("load: of which decoding"); |
|
| 1526 | 1533 | State::read_index_changes(&changes[4..]).ok() |
|
| 1527 | 1534 | } |
|
| 1528 | 1535 | ||
| … | 20 unchanged lines |
||
| 1549 | 1556 | w.object(h); |
|
| 1550 | 1557 | } |
|
| 1551 | 1558 | let split = 4 + u32::from_le_bytes(body[..4].try_into().unwrap()) as usize; |
|
| 1552 | - | w.fixed(blake3::hash(&body[..split]).as_bytes()); |
|
| 1559 | + | w.fixed( |
|
| 1560 | + | blake3::Hasher::new() |
|
| 1561 | + | .update_rayon(&body[..split]) |
|
| 1562 | + | .finalize() |
|
| 1563 | + | .as_bytes(), |
|
| 1564 | + | ); |
|
| 1553 | 1565 | let mut sum = blake3::Hasher::new(); |
|
| 1554 | 1566 | sum.update_rayon(&body[split..]); |
|
| 1555 | 1567 | w.fixed(sum.finalize().as_bytes()); |
|
| … | 291 unchanged lines |
||
| 1847 | 1859 | if self.bare { |
|
| 1848 | 1860 | return self.log_state(&heads); |
|
| 1849 | 1861 | } |
|
| 1850 | - | let (wc_hash, _) = self.working_copy()?; |
|
| 1862 | + | let (wc_hash, wc) = self.working_copy()?; |
|
| 1851 | 1863 | let mut all = heads.clone(); |
|
| 1852 | 1864 | all.push(wc_hash); |
|
| 1853 | 1865 | let head_set: BTreeSet<ChangeHash> = heads.into_iter().collect(); |
|
| … | 6 unchanged lines |
||
| 1860 | 1872 | .map(|(h, m)| (*h, m.clone())) |
|
| 1861 | 1873 | .collect() |
|
| 1862 | 1874 | } else { |
|
| 1863 | - | self.metas_of(&all)? |
|
| 1875 | + | // The working copy's change is in hand, and is never in the |
|
| 1876 | + | // state index: its metadata is not looked up again. |
|
| 1877 | + | let mut below: Vec<ChangeHash> = head_set.iter().copied().collect(); |
|
| 1878 | + | below.extend(wc.deps.iter().copied()); |
|
| 1879 | + | let _t = crate::timing::phase("log: metadata"); |
|
| 1880 | + | let mut metas = self.metas_of(&below)?; |
|
| 1881 | + | if !metas.iter().any(|(h, _)| *h == wc_hash) { |
|
| 1882 | + | metas.push((wc_hash, crate::state::ChangeMeta::of(&wc))); |
|
| 1883 | + | } |
|
| 1884 | + | metas |
|
| 1864 | 1885 | }; |
|
| 1865 | 1886 | for (h, meta) in metas { |
|
| 1866 | 1887 | entries.push(LogEntry { |
|
| … | 3 unchanged lines |
||
| 1870 | 1891 | is_head: head_set.contains(&h), |
|
| 1871 | 1892 | }); |
|
| 1872 | 1893 | } |
|
| 1873 | - | entries.sort_by(|a, b| { |
|
| 1894 | + | let _sorting = crate::timing::phase("log: sort"); |
|
| 1895 | + | // The hash makes the order total, so an unstable sort gives the same. |
|
| 1896 | + | entries.sort_unstable_by(|a, b| { |
|
| 1874 | 1897 | b.is_working_copy |
|
| 1875 | 1898 | .cmp(&a.is_working_copy) |
|
| 1876 | 1899 | .then(b.change.hlc.cmp(&a.change.hlc)) |
|
| … | 36 unchanged lines |
||
| 1913 | 1936 | is_working_copy: false, |
|
| 1914 | 1937 | }) |
|
| 1915 | 1938 | .collect(); |
|
| 1916 | - | entries.sort_by(|a, b| b.change.hlc.cmp(&a.change.hlc).then(b.hash.cmp(&a.hash))); |
|
| 1939 | + | entries.sort_unstable_by(|a, b| b.change.hlc.cmp(&a.change.hlc).then(b.hash.cmp(&a.hash))); |
|
| 1917 | 1940 | Ok(entries) |
|
| 1918 | 1941 | } |
|
| 1919 | 1942 | ||
| … | 8 unchanged lines |
||
| 1928 | 1951 | // The state index holds the metadata of its heads' closure: a |
|
| 1929 | 1952 | // change found there is not read from the store (a change object |
|
| 1930 | 1953 | // can be as large as the tree it adds). |
|
| 1931 | - | let indexed = self.read_state_index_changes(); |
|
| 1954 | + | let mut indexed = self.read_state_index_changes(); |
|
| 1955 | + | let _walk = crate::timing::phase("metadata: walk"); |
|
| 1932 | 1956 | let (mut stored, mut from_store) = (0usize, std::time::Duration::ZERO); |
|
| 1933 | - | let mut seen: HashSet<ChangeHash> = HashSet::new(); |
|
| 1957 | + | let mut seen: crate::fasthash::FastSet<ChangeHash> = Default::default(); |
|
| 1934 | 1958 | let mut stack: Vec<ChangeHash> = heads.to_vec(); |
|
| 1935 | - | let mut out = Vec::new(); |
|
| 1959 | + | let mut out = Vec::with_capacity(indexed.as_ref().map_or(0, |m| m.len())); |
|
| 1936 | 1960 | while let Some(h) = stack.pop() { |
|
| 1937 | 1961 | if !seen.insert(h) { |
|
| 1938 | 1962 | continue; |
|
| 1939 | 1963 | } |
|
| 1940 | - | if let Some(meta) = indexed.as_ref().and_then(|m| m.get(&h)) { |
|
| 1964 | + | // Taken out of the index's map, each once: not copied. |
|
| 1965 | + | if let Some(meta) = indexed.as_mut().and_then(|m| m.remove(&h)) { |
|
| 1941 | 1966 | #[cfg(test)] |
|
| 1942 | 1967 | assert_eq!( |
|
| 1943 | - | **meta, |
|
| 1968 | + | *meta, |
|
| 1944 | 1969 | self.store.get_change_meta(&h)?, |
|
| 1945 | 1970 | "the state index holds a change's metadata" |
|
| 1946 | 1971 | ); |
|
| 1972 | + | let meta = std::sync::Arc::unwrap_or_clone(meta); |
|
| 1947 | 1973 | stack.extend(meta.deps.iter().copied()); |
|
| 1948 | - | out.push((h, (**meta).clone())); |
|
| 1974 | + | out.push((h, meta)); |
|
| 1949 | 1975 | continue; |
|
| 1950 | 1976 | } |
|
| 1951 | 1977 | let started = std::time::Instant::now(); |
|
| … | 65 unchanged lines |
||
| 2017 | 2043 | is_head: head_set.contains(h), |
|
| 2018 | 2044 | }) |
|
| 2019 | 2045 | .collect(); |
|
| 2020 | - | entries.sort_by(|a, b| b.change.hlc.cmp(&a.change.hlc).then(b.hash.cmp(&a.hash))); |
|
| 2046 | + | entries.sort_unstable_by(|a, b| b.change.hlc.cmp(&a.change.hlc).then(b.hash.cmp(&a.hash))); |
|
| 2021 | 2047 | entries |
|
| 2022 | 2048 | } |
|
| 2023 | 2049 | ||
| … | 264 unchanged lines |
modified crates/oxy-core/src/state.rs +16 −7
| … | 125 unchanged lines |
||
| 126 | 126 | struct Deferred { |
|
| 127 | 127 | body: std::sync::OnceLock<DeferredBody>, |
|
| 128 | 128 | /// The body as the index encodes it, decoded into `body` on first use. |
|
| 129 | - | raw: Option<(Arc<[u8]>, std::ops::Range<usize>)>, |
|
| 129 | + | raw: Option<(Arc<Vec<u8>>, std::ops::Range<usize>)>, |
|
| 130 | 130 | /// Its graph key, once worked out (or read from an index); reset by |
|
| 131 | 131 | /// any edit added. |
|
| 132 | 132 | key: std::sync::OnceLock<[u8; 32]>, |
|
| … | 225 unchanged lines |
||
| 358 | 358 | ||
| 359 | 359 | /// The state an index holds (see [`State::write_index`]). |
|
| 360 | 360 | pub fn read_index(bytes: &[u8]) -> Result<State, ObjectError> { |
|
| 361 | + | Self::read_index_in(&Arc::new(bytes.to_vec()), 0) |
|
| 362 | + | } |
|
| 363 | + | ||
| 364 | + | /// As [`State::read_index`], for an index at `at` in `file`, which the |
|
| 365 | + | /// files' histories keep pointing into rather than copying: most of an |
|
| 366 | + | /// index is histories a command never decodes. |
|
| 367 | + | pub fn read_index_in(file: &Arc<Vec<u8>>, at: usize) -> Result<State, ObjectError> { |
|
| 361 | 368 | use crate::flat::Reader; |
|
| 362 | 369 | let bad = |what: &str| ObjectError::Schema(format!("state index: {what}")); |
|
| 370 | + | let bytes = file.get(at..).ok_or_else(|| bad("truncated"))?; |
|
| 363 | 371 | let mut r = Reader::new(bytes); |
|
| 364 | 372 | let mut st = State::new_history_only(); |
|
| 365 | 373 | { |
|
| … | 13 unchanged lines |
||
| 379 | 387 | if !r.at_end() { |
|
| 380 | 388 | return Err(bad("trailing bytes")); |
|
| 381 | 389 | } |
|
| 390 | + | let start = deferred_bytes.as_ptr() as usize - file.as_ptr() as usize; |
|
| 382 | 391 | let (tree, deferred) = rayon::join( |
|
| 383 | 392 | || TreeGraph::read_flat(&mut Reader::new(tree_bytes)), |
|
| 384 | - | || Self::read_deferred(deferred_bytes), |
|
| 393 | + | || Self::read_deferred(file, start..start + deferred_bytes.len()), |
|
| 385 | 394 | ); |
|
| 386 | 395 | st.tree = tree?; |
|
| 387 | 396 | st.deferred = Some(deferred?); |
|
| … | 25 unchanged lines |
||
| 413 | 422 | /// The deferred block: each node with its graph key and its body, |
|
| 414 | 423 | /// left encoded (see [`Deferred`]). |
|
| 415 | 424 | fn read_deferred( |
|
| 416 | - | bytes: &[u8], |
|
| 425 | + | file: &Arc<Vec<u8>>, |
|
| 426 | + | within: std::ops::Range<usize>, |
|
| 417 | 427 | ) -> Result<crate::fasthash::FastMap<NodeId, Arc<Deferred>>, ObjectError> { |
|
| 418 | 428 | use crate::flat::Reader; |
|
| 419 | 429 | let _t = crate::timing::phase("index: deferred"); |
|
| 420 | - | let all: Arc<[u8]> = Arc::from(bytes); |
|
| 421 | - | let mut r = Reader::new(bytes); |
|
| 430 | + | let mut r = Reader::new(&file[within]); |
|
| 422 | 431 | let mut deferred = crate::fasthash::FastMap::default(); |
|
| 423 | 432 | let n = r.count()?; |
|
| 424 | 433 | deferred.reserve(n); |
|
| … | 1 unchanged lines |
||
| 426 | 435 | let id = NodeId(r.fixed::<16>()?); |
|
| 427 | 436 | let key = r.fixed::<32>()?; |
|
| 428 | 437 | let body = r.bytes()?; |
|
| 429 | - | let start = body.as_ptr() as usize - bytes.as_ptr() as usize; |
|
| 438 | + | let start = body.as_ptr() as usize - file.as_ptr() as usize; |
|
| 430 | 439 | let d = Deferred { |
|
| 431 | - | raw: Some((all.clone(), start..start + body.len())), |
|
| 440 | + | raw: Some((file.clone(), start..start + body.len())), |
|
| 432 | 441 | key: std::sync::OnceLock::from(key), |
|
| 433 | 442 | ..Deferred::default() |
|
| 434 | 443 | }; |
|
| … | 2295 unchanged lines |
modified src/cli/workspace.rs +73 −2
| … | 685 unchanged lines |
||
| 686 | 686 | } else { |
|
| 687 | 687 | &entries |
|
| 688 | 688 | }; |
|
| 689 | - | let id_prefix = unique_prefixes(all.iter().map(|e| e.change.change_id.to_hex())); |
|
| 689 | + | let prefixes = oxy_core::timing::phase("log: id prefixes"); |
|
| 690 | + | let id_prefix = id_prefixes( |
|
| 691 | + | all.iter().map(|e| e.change.change_id.0).collect(), |
|
| 692 | + | entries.iter().take(shown).map(|e| e.change.change_id.0), |
|
| 693 | + | ); |
|
| 694 | + | drop(prefixes); |
|
| 690 | 695 | let labels = reference_labels(&repo)?; |
|
| 691 | 696 | for (e, row) in entries.iter().take(shown).zip(&rows) { |
|
| 692 | 697 | let c = &e.change; |
|
| … | 3 unchanged lines |
||
| 696 | 701 | e.is_working_copy && conflicted, |
|
| 697 | 702 | ); |
|
| 698 | 703 | let id = c.change_id.to_hex(); |
|
| 699 | - | let n = id_prefix.get(&id).copied().unwrap_or(8).min(8); |
|
| 704 | + | let n = id_prefix.get(&c.change_id.0).copied().unwrap_or(8).min(8); |
|
| 700 | 705 | let mut head = vec![format!("{}{}", ui::id(&id[..n]), ui::dim(&id[n..8]))]; |
|
| 701 | 706 | head.push(ui::author(author_name(&repo, c))); |
|
| 702 | 707 | head.push(ui::dim(ui::ago(c.hlc.wall_ms))); |
|
| … | 151 unchanged lines |
||
| 854 | 859 | } |
|
| 855 | 860 | ||
| 856 | 861 | /// For each hex ID, the length of its shortest prefix no other ID shares. |
|
| 862 | + | /// The shortest prefix, in hex digits, that names each of `wanted` among |
|
| 863 | + | /// `all`: worked out for the few shown rather than for the whole history. |
|
| 864 | + | fn id_prefixes( |
|
| 865 | + | mut all: Vec<[u8; 16]>, |
|
| 866 | + | wanted: impl Iterator<Item = [u8; 16]>, |
|
| 867 | + | ) -> std::collections::HashMap<[u8; 16], usize> { |
|
| 868 | + | all.sort_unstable(); |
|
| 869 | + | all.dedup(); |
|
| 870 | + | // Hex digits two ids share at the start. |
|
| 871 | + | let common = |a: &[u8; 16], b: &[u8; 16]| { |
|
| 872 | + | let bytes = a.iter().zip(b).take_while(|(x, y)| x == y).count(); |
|
| 873 | + | match (a.get(bytes), b.get(bytes)) { |
|
| 874 | + | (Some(x), Some(y)) if x >> 4 == y >> 4 => 2 * bytes + 1, |
|
| 875 | + | _ => 2 * bytes, |
|
| 876 | + | } |
|
| 877 | + | }; |
|
| 878 | + | let mut out = std::collections::HashMap::new(); |
|
| 879 | + | for id in wanted { |
|
| 880 | + | let Ok(i) = all.binary_search(&id) else { |
|
| 881 | + | continue; |
|
| 882 | + | }; |
|
| 883 | + | let before = i.checked_sub(1).map_or(0, |j| common(&all[j], &id)); |
|
| 884 | + | let after = all.get(i + 1).map_or(0, |n| common(&id, n)); |
|
| 885 | + | out.insert(id, before.max(after) + 1); |
|
| 886 | + | } |
|
| 887 | + | out |
|
| 888 | + | } |
|
| 889 | + | ||
| 857 | 890 | fn unique_prefixes(ids: impl Iterator<Item = String>) -> std::collections::HashMap<String, usize> { |
|
| 858 | 891 | let mut v: Vec<String> = ids.collect(); |
|
| 859 | 892 | v.sort(); |
|
| … | 1841 unchanged lines |
||
| 2701 | 2734 | )); |
|
| 2702 | 2735 | where_now(&repo); |
|
| 2703 | 2736 | Ok(0) |
|
| 2737 | + | } |
|
| 2738 | + | ||
| 2739 | + | #[cfg(test)] |
|
| 2740 | + | mod tests { |
|
| 2741 | + | use super::*; |
|
| 2742 | + | ||
| 2743 | + | #[test] |
|
| 2744 | + | fn id_prefixes_agree_with_the_whole_list() { |
|
| 2745 | + | // Ids that share long prefixes, and some that differ only in a |
|
| 2746 | + | // half byte, from a fixed sequence. |
|
| 2747 | + | let mut x: u64 = 0x9e37_79b9_7f4a_7c15; |
|
| 2748 | + | let mut next = || { |
|
| 2749 | + | x ^= x << 13; |
|
| 2750 | + | x ^= x >> 7; |
|
| 2751 | + | x ^= x << 17; |
|
| 2752 | + | x |
|
| 2753 | + | }; |
|
| 2754 | + | let mut ids: Vec<[u8; 16]> = Vec::new(); |
|
| 2755 | + | for _ in 0..2000 { |
|
| 2756 | + | let mut id = [0u8; 16]; |
|
| 2757 | + | id[..8].copy_from_slice(&next().to_le_bytes()); |
|
| 2758 | + | id[8..].copy_from_slice(&next().to_le_bytes()); |
|
| 2759 | + | let share = (next() % 4) as usize; |
|
| 2760 | + | if let Some(prev) = ids.last() { |
|
| 2761 | + | id[..share].copy_from_slice(&prev[..share]); |
|
| 2762 | + | if next() % 2 == 0 { |
|
| 2763 | + | id[share] = (prev[share] & 0xf0) | (id[share] & 0x0f); |
|
| 2764 | + | } |
|
| 2765 | + | } |
|
| 2766 | + | ids.push(id); |
|
| 2767 | + | } |
|
| 2768 | + | let hex = |id: &[u8; 16]| oxy_core::ids::ChangeId(*id).to_hex(); |
|
| 2769 | + | let whole = unique_prefixes(ids.iter().map(hex)); |
|
| 2770 | + | let some = id_prefixes(ids.clone(), ids.iter().copied()); |
|
| 2771 | + | for id in &ids { |
|
| 2772 | + | assert_eq!(some[id], whole[&hex(id)], "{}", hex(id)); |
|
| 2773 | + | } |
|
| 2774 | + | } |
|
| 2704 | 2775 | } |