Skip to content

[perf] Reclaim cleared keys by expiring them instead of deleting - #156

Open
Chase-Rong wants to merge 1 commit into
Ascend:mainfrom
Chase-Rong:perf/clear
Open

[perf] Reclaim cleared keys by expiring them instead of deleting#156
Chase-Rong wants to merge 1 commit into
Ascend:mainfrom
Chase-Rong:perf/clear

Conversation

@Chase-Rong

Copy link
Copy Markdown
Contributor

Background

Measured on a verl PPO run (main_ppo_sync, Yuanrong backend, 28 fields per sample), with a probe splitting the call into its four legs — KV_RETRIEVE_METAMARK_CLEARING → storage release → CLEAR_META:

1 node (512 samples) 2 nodes (1024 samples)
total 493 ms 1260 ms
yuanrong delete 412 ms (83%) 1090 ms (87%)
TQ side 78 ms 158 ms

A clear touches B × F keys — 28672 of them at 1024 samples — and datasystem's
delete is priced per key.

Why clear was superlinear across nodes

delete gets more expensive the further the data is:

delete on a key written by… cost
the local worker 28.7 us/key
the other node 38.0 us/key (+32%)

In a 2-node run roughly half the keys live on the remote node, and the driver deletes them all from one process, so every remote key pays a forwarding hop. The batch doubles and the unit price rises — hence 2.55x wall clock for 2x the work.

This is the distinction that matters: the extra time is not "more work", it is "each unit of work got more expensive". Per-key cost separates the two; a wall-clock ratio on its own cannot.

expire is a metadata mark. It never touches the node holding the data, so it costs 9.2 us/key on one node and 9.2 us/key on two — identical. Switching clear to expire brings scaling back to 2.01x.

What changed

A new backend.Yuanrong.data_ttl_second (default 3600, 0 keeps the current
synchronous delete
):

    # Lifetime in seconds written onto every stored key, refreshed on each write.
    data_ttl_second: 128
  • GeneralKVClientAdapter.clear calls expire(keys, CLEAR_EXPIRE_SECOND=1) when a TTL is configured, and delete when it is not.
  • mset_zero_copy passes that TTL to mcreate on every write.

Both halves are required, and this is the subtle part of the change. clear_meta returns cleared global_indexes to the reusable pool, so a later put rebuilds the exact same {index}@{field} keys — and a TTL survives a rewrite unless the rewrite
states one explicitly. ttl_second=0 means "delete manually", which the backend reads as "leave the existing deadline alone", not "clear it". Verified directly:

written with ttl=3, then… alive after the deadline
rewritten with ttl_second=600 10/10
left alone 0/10
rewritten with ttl_second=0 (a plain put) 0/10

So expiring in clear without passing a TTL on writes would silently delete freshly written training data — and because a clear is ~60 s away from the next put in practice while the deadline is 1 s, it would almost never reproduce.

The TTL argument itself is free: mcreate with a TTL is 66.0 ms vs 78.5 ms without,
i.e. within noise.

Two smaller wins on the same path, both scaling with batch size:

  • _route_to_strategies resolved the owning strategy once per item; a batch written by one backend carries one distinct tag, so it now decides once per distinct tag (12.8 → 1.6 ms at 28672 keys). Mixed tags, unmatched tags and the get path's ignore_unmatched=False raise all fall through to the original loop.
  • clear_data asked _get_shape_type_custom_backend_meta_list for a triple but only needs the routing tags; shapes/dtypes were built and discarded (8.4 → 0 ms).

Results

Full verl PPO runs, 6 steps per configuration, medians of 6 kv_clear samples. The cluster topology is matched to the node count (p2 detached for the 1-node rounds), and the two variants of a given node count run back to back.

base 1-node this PR 1-node base 2-node this PR 2-node
total 493.2 208.1 (2.37x) 1259.5 418.6 (3.01x)
yuanrong side 412.0 131.2 (3.14x) 1089.7 265.0 (4.11x)
TQ side 77.8 71.0 158.4 139.6
retrieve_meta (rpc) 54.6 55.2 111.0 111.2
route_to_strategies 6.4 0.8 12.8 1.6
get_shape_type 3.3 0 8.4 0
per-key storage op 28.7 us delete 9.2 us expire 38.0 us delete 9.2 us expire

Scaling 1 node → 2 nodes (2.00x is perfectly linear):

total yuanrong TQ side
base 2.55x 2.65x 2.04x
this PR 2.01x 2.02x 1.97x

Run-to-run spread also tightens, because the remaining cost no longer depends on where
the data sits:

min / median / max outlier ratio
base 1-node 486.8 / 493.2 / 748.2 1.52x
this PR 1-node 206.6 / 208.1 / 210.6 1.01x
base 2-node 1228.2 / 1259.5 / 2098.8 1.67x
this PR 2-node 407.4 / 418.6 / 434.8 1.04x

The first clear of every base round is a cold-start outlier (748 ms / 2099 ms), which
is why medians are used throughout.

Correctness

Visibility is unchanged. For the KV interface, reachability is decided by the controller's keys_mapping, not by production_status: async_kv_batch_get is kv_retrieve_meta(create=False) + get_data, and neither consults it (the symbol does not appear in storage/managers/base.py at all). mark_clearing therefore guards the sampler path only — the boundary for KV callers is clear_meta. Once that has run the keys cannot be named, whether or not the bytes are still resident. The TTL only bounds when the space comes back.

No stale reads. Between the storage call and clear_meta there is a pre-existing non-atomic window. With delete a concurrent kv_batch_get in that window reads None; with expire it reads intact old data. Consistency is slightly better, and the window shrinks from 389 ms to 134 ms.

Partial rewrites are safe. If a cleared index is reused and the new put only writes some fields, the old 5@B is still resident until its deadline. It is unreachable: _generate_keys only builds keys for the new metadata's field_names, and clear_meta already dropped the old fields via field_meta.remove_samples. Metadata is the sole source of key reachability, so the chain is closed.

Crash cleanup comes for free: keys orphaned by a killed process now expire instead
of leaking.

NPU tensor path is untouched. DsTensorClient offers neither expire nor a TTL argument, so it keeps deleting; clear already routes per strategy, so the two paths separate on their own.

The TTL must exceed the lifetime of any single batch — noted in the config comment.

Testing

  • tests/test_yuanrong_client_zero_copy.py: 3 new cases — clear dispatches to expire/delete per data_ttl_second and the TTL reaches mcreate (parametrised over 0 and 3600), plus batching past GET_CLEAR_KEYS_LIMIT. test_mset_mget_p2p's mcreate mock signature updated for the new keyword. 4 passed in-container.
  • End-to-end against a live datasystem, both settings: data_ttl_second=0 deletes at 24.7 us/key and keys vanish immediately (byte-for-byte the old behaviour); data_ttl_second=3600 expires at 9.9 us/key, rewritten keys survive 1000/1000 and cleared keys are reclaimed.
  • Equivalence of the _route_to_strategies fast path against the loop across 12 cases: single tag, mixed tags, unsupported tag, empty tags, one-tag-plus-empty, empty input, single strategy, the get path's ignore_unmatched=False (ValueError preserved), and the put path's value routing.
  • 4 full verl PPO runs (6 steps each) for the numbers above. Topology asserted per
    round: the 1-node logs contain zero references to the second node.
  • python -m compileall -q transfer_queue tutorial tests clean.

A kv_clear costs more than a put: measured on a verl PPO run, 493 ms for 512
samples and 1260 ms for 1024, of which yuanrong's delete is 83%. delete does not
parallelise (16 threads and 8 separate connections both come out slower than
serial, so the serialisation is server-side), and it gets more expensive the
further the data is: 28.7 us/key when the key was written locally, 38.0 us/key
when another node wrote it. That is what made clear superlinear across nodes -
2.55x for twice the batch.

datasystem's expire is ~3x cheaper than delete and, being a metadata mark, does
not pay the remote hop: 9.2 us/key whether one node or two. So clear now expires
keys when a TTL is configured, and scaling returns to 2.01x.

Doing this safely needs both halves of the change. clear_meta returns cleared
global_indexes to the reusable pool, so a later put rebuilds the very same
'{index}@{field}' keys - and a TTL survives a rewrite unless the rewrite states
one explicitly (ttl_second=0 means "delete manually", which the backend reads as
"leave the existing deadline alone", not "clear it"). mcreate therefore passes
the configured TTL on every write, re-arming the clock. Expiring in clear without
that would silently delete freshly written training data.

Visibility is unchanged. For the KV interface, reachability is decided by the
controller's keys_mapping, not by production_status - kv_retrieve_meta and
get_data never consult it - so once clear_meta has run the keys cannot be named,
whether or not the bytes are still resident. The TTL only bounds when the space
comes back, and it also reclaims keys orphaned by a crash. In the pre-existing
window between the storage call and clear_meta, a concurrent read now sees intact
old data rather than the None it would get from a completed delete.

data_ttl_second: 0 keeps the synchronous delete. DsTensorClient offers neither
expire nor a TTL argument, so the NPU tensor path keeps deleting; clear already
routes per strategy.

Two smaller wins on the same path, both scaling with batch size:
  - route once per distinct backend tag instead of once per item, since a batch
    written by one backend carries one tag (12.8 -> 1.6 ms at 28672 keys)
  - stop building shapes/dtypes in clear_data, which only needs routing tags
    (8.4 -> 0 ms)
@ascend-robot

Copy link
Copy Markdown

CLA Signature Guide

@Chase-Rong , thanks for your pull request.

The following commit(s) are not associated with a signed Contributor License Agreement (CLA).

Commit Reason
[6f021b4 [perf] Reclaim cleared keys by ...](6f021b4) the email used in the commit is not linked to a signed CLA!
please verify that it matches the email you used when signing the CLA.

To sign CLA, click here.

To check if your email is configured correctly, refer to the FAQs.

Once you've signed the CLA or updating your email, please comment /check-cla to revalidate CLA status.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6f021b4c1f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

# orphaned by a crash. Set to 0 to delete synchronously instead.
# Must exceed the lifetime of any single batch. Ignored by the NPU tensor path,
# which datasystem does not offer a TTL for.
data_ttl_second: 120

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the Yuanrong data TTL opt-in by default

Setting this default to 120 makes every general Yuanrong value expire two minutes after its write even when it remains live in controller metadata; reads do not refresh the deadline. Any supported workload that queues or retains a batch for longer than two minutes will therefore receive missing values without ever clearing them, whereas the previous default retained data until an explicit clear. Keep the default at 0 unless the lifecycle can enforce a safe upper bound.

AGENTS.md reference: AGENTS.md:L17-L18

Useful? React with 👍 / 👎.

Comment on lines +116 to +120
# Lifetime in seconds written onto every stored key, refreshed on each write.
# When > 0, clear marks keys to expire instead of deleting them, which datasystem
# does ~3x faster; keys become unreachable as soon as clear releases the metadata,
# and the TTL only bounds when the space is reclaimed. It also cleans up keys
# orphaned by a crash. Set to 0 to delete synchronously instead.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Replace the benchmark-heavy configuration comment

This new seven-line comment includes the workload-specific ~3x faster benchmark, while the repository requires comments to be concise 2–4-line explanations of why and explicitly forbids single-run metrics. Reduce it to the stable TTL/reclamation semantics and safety constraint so the shipped configuration does not retain stale benchmark evidence.

AGENTS.md reference: AGENTS.md:L24-L26

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants